Compare commits
8 Commits
1d7b22f4d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636614955d | ||
|
|
32434e0ef3 | ||
|
|
990ca14c63 | ||
|
|
09186e23b9 | ||
| bd748eae8f | |||
| b87a79bc83 | |||
| 1f00fddd22 | |||
| 4423e00b66 |
5
.github/workflows/backend-tests.yml
vendored
5
.github/workflows/backend-tests.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- ".github/workflows/backend-tests.yml"
|
||||
# Reused by deploy.yml so production is only updated from a green build.
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
@@ -50,7 +52,8 @@ jobs:
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
# v3 of the artifact actions was shut down by GitHub (jobs using it fail).
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.python-version }}
|
||||
path: backend/coverage.xml
|
||||
|
||||
15
.github/workflows/deploy.yml
vendored
15
.github/workflows/deploy.yml
vendored
@@ -4,11 +4,24 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Never run two deploys at once; a newer push supersedes a queued one.
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
uses: ./.github/workflows/backend-tests.yml
|
||||
|
||||
frontend-build:
|
||||
uses: ./.github/workflows/frontend-checks.yml
|
||||
|
||||
deploy:
|
||||
name: Trigger production update
|
||||
runs-on: ubuntu-latest
|
||||
needs: []
|
||||
# Previously `needs: []`: every push to main reached production even when
|
||||
# the test suite was red.
|
||||
needs: [backend-tests, frontend-build]
|
||||
steps:
|
||||
- name: SSH and trigger update
|
||||
uses: appleboy/ssh-action@v1
|
||||
|
||||
62
.github/workflows/frontend-checks.yml
vendored
Normal file
62
.github/workflows/frontend-checks.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Frontend Checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-checks.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-checks.yml"
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & typecheck
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# npm install (not ci), same as the Dockerfile: the lockfile is produced on
|
||||
# Windows and lacks some Linux-only optional deps, which makes `npm ci` fail.
|
||||
- name: Install dependencies
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
# Russian sessions do not load en.json as a fallback (lib/i18n.ts), so a
|
||||
# key present only in en.json would show up raw in the Russian UI.
|
||||
- name: Locale keys parity (ru covers en)
|
||||
run: |
|
||||
node -e '
|
||||
const flat = (o, p = "") => Object.entries(o).flatMap(([k, v]) =>
|
||||
v && typeof v === "object" ? flat(v, p + k + ".") : [p + k]);
|
||||
const ru = new Set(flat(require("./src/locales/ru.json")));
|
||||
const missing = flat(require("./src/locales/en.json")).filter((k) => !ru.has(k));
|
||||
if (missing.length) { console.error("Missing in ru.json:\n" + missing.join("\n")); process.exit(1); }
|
||||
'
|
||||
|
||||
# The production image only runs `vite build`, so this is the gate that
|
||||
# must stay green.
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
# Reported, not yet enforced: flip to blocking once the existing
|
||||
# type and lint findings are cleared.
|
||||
- name: Typecheck
|
||||
run: npx tsc -b
|
||||
continue-on-error: true
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -6,8 +6,82 @@ All notable changes to PVEmanager will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **Tenants can no longer act on other users' guests** — 28 VM/LXC endpoints (command execution via guest agent and `pct exec`, config read/write, disks, cloud-init, unlock, HA, metrics, RRD) and the guest firewall endpoints checked only the permission, not ownership. Any user with `vm:console` (part of the stock `user` role) could run commands in any VM. Backup create/restore/list and Proxmox task logs are scoped the same way
|
||||
- **Server-scoped endpoints honour server assignment** — node syslog/services/APT, cluster firewall, disks/ZFS, networks, pools, SDN, backups and templates of servers that are not assigned to a tenant (directly or via a workspace) now return 404
|
||||
- **Privilege escalation in user management closed** — `user:create` could mint administrators (`is_admin` was taken from the request), `user:update` could reset the password of an account that is admin through its role or assign the admin role to oneself, `role:manage` could grant permissions the actor did not hold
|
||||
- **Script catalog hardening** — `script:execute` no longer runs scripts on any server/guest: node scripts need `node:manage`, guest scripts need access to the guest. Git sources: option-injection-safe `git clone`, URL/branch/glob validation, symlinks and paths outside the clone are ignored (a repository could import `/proc/self/environ`). Parameter names and the interpreter are validated; temp files get random names
|
||||
- **WebSocket**: `/ws/tasks` validates the session like the REST API (a logged-out token kept streaming); channel subscriptions and RRD requests are authorized (tenants could stream other users' guest metrics); the shared instance list is filtered per subscriber
|
||||
- **Password reset links ignore the request's `Origin`/`Host`** (reset poisoning) — new `PANEL_URL` setting, filled by `deploy.sh` when a domain is configured
|
||||
- **Plaintext passwords no longer reach the audit log** — form-encoded bodies (e.g. `/api/auth/change-password`) were stored raw; 2FA codes and private keys are masked too
|
||||
- **Stored XSS via template group icons** fixed (rendered with `dangerouslySetInnerHTML`, editable with `template:manage`)
|
||||
- **SSRF**: `/api/images/resolve-filename` requires `template:manage` and refuses loopback/link-local targets on every redirect hop
|
||||
- **Client IP can no longer be spoofed** with `X-Forwarded-For` to dodge brute-force blocking; proxy headers are trusted only from private peers
|
||||
- **2FA**: TOTP codes are single-use (RFC 6238), the TOTP secret is stored encrypted (migration 44), login timing no longer reveals whether a username exists
|
||||
- **Sessions**: changing a password ends the user's other sessions (both endpoints; the profile endpoint also enforces the password policy now), `stop-impersonate` requires a live session, a placeholder `SECRET_KEY` is replaced by a generated key stored on the data volume
|
||||
- **SSH host keys are pinned on first use (TOFU)** — the panel used to accept any key presented by a node (`WarningPolicy`), so anyone able to intercept the connection could pose as the node and receive the root password. The first key seen is now stored in `/app/data/ssh_known_hosts` (`SSH_KNOWN_HOSTS_FILE`), a different key later is refused with a clear error, and the new **Reset SSH host key** button on the server card (`DELETE /proxmox/api/servers/{id}/ssh-host-key`, `server:update`) clears the pin after a node reinstall. `SSH_REJECT_UNKNOWN_HOSTS=true` keeps strict mode
|
||||
- **Quotas** apply to clones and App Store installs; App Store install validates storage/bridge/node and server access
|
||||
- VM notifications and resource alerts go to the guest's owner and administrators only (they were sent to every user); WebSocket broadcasts no longer carry other tenants' guest names or the new owner's e-mail
|
||||
- **Consoles no longer hand out the panel's Proxmox root ticket** — opening a VNC console or terminal returned the `PVEAuthCookie` of the panel's Proxmox account (usually `root@pam`) and the node IP to the browser, so any tenant with `vm:console` on their own guest got full API access to the cluster for two hours. The ticket now stays on the backend; the browser gets a single-use handle bound to the user and guest
|
||||
- **Bulk operations check every guest** — `/api/bulk-operation` checked only `vm:manage`, and a role holding `vm:*` passed it without being treated as global elsewhere, so it could stop or delete other tenants' guests. Each item now needs the action's own permission (as the single-guest endpoints: `vm:start`, `vm:stop`, `vm:delete`, `vm:migrate`/`lxc:migrate`) and access to the guest — owners can use bulk actions on their own guests
|
||||
- **Backups are scoped to the guest's owner** — a tenant could restore someone else's archive into their own guest (taking over its disks), restore to a new untracked VMID, force-overwrite foreign guests with bulk restore, delete foreign backup files and read any task log via `/api/backups/task`. Scheduled backup jobs validate VMIDs against ownership (with `keep_last` pruning, a foreign VMID wiped that guest's backups), validate cron expressions, and `toggle` / `run-now` check the job owner
|
||||
- **Command injection in the App Store golden template** — `POST /api/appstore/golden-template` put `template_storage`, `rootfs_storage` and `bridge` from the request into root shell commands on the node (`app:manage` → root on the node); they are validated now, and the server must be visible to the user
|
||||
- **Deploy from template honours server assignment** — the request names a template, so the router-level check did not apply and a tenant could deploy onto any server; the template list shows tenants only templates of their servers
|
||||
- App Store logos (often SVG from third-party catalogs) are served with a sandboxing CSP — opened directly, a script inside ran on the panel's origin next to the session token; the global security-headers middleware no longer overrides an endpoint's stricter CSP
|
||||
- "Open app" after an App Store install opens the app without `window.opener` (the third-party app could navigate the panel tab)
|
||||
- **LXC / ISO creation could read other guests' data** — `ostemplate` accepted a vzdump archive (`pct create` from a backup is a restore, so a tenant got another guest's filesystem) and `iso` / `extra_iso` accepted any volume (another guest's disk attached as a CD-ROM); only `<storage>:vztmpl/…` and `<storage>:iso/…` are accepted now. Both endpoints honour server assignment (the server comes from the body), and privileged containers (root in them is root on the node) require `vm:manage`
|
||||
- **Parallel creations no longer bypass `max_instances`** — the instance row appears only after the clone finishes, so N simultaneous deploys all passed the check; queued/running deploys, clones, ISO VMs and App Store installs now count
|
||||
- **Node names and other identifiers are validated before reaching the Proxmox API** — `node`, path segments, storage ids, backup volume ids and UPIDs from requests were inserted into Proxmox API URL paths unescaped, so a crafted value could make a request authorized for one guest address a different resource. Every `/proxmox` route (HTTP and WebSocket) now rejects such values, and request bodies validate node names
|
||||
- **Open consoles close when access ends** — WebSocket access was checked only at the handshake, so a VNC/terminal/serial console or a root node shell stayed usable after logout, a password change, a terminated session, an account lock or a revoked permission / guest ownership. Access is now re-checked every 30 s on all console bridges and `/ws/tasks`
|
||||
- The panel page itself is served with `frame-ancestors 'self'` / `X-Frame-Options`, `nosniff` and a referrer policy: the security headers were set only on API responses (the outer nginx's ones were not inherited by the SSL server block), so any site could frame the panel
|
||||
- Without the SSL profile, `compose.yml` published the API on every host interface (port 8000), bypassing nginx; it is bound to `127.0.0.1` now (the UI goes through the frontend's nginx as before)
|
||||
- The SMTP password and Telegram bot token are stored encrypted (migration 46 encrypts existing values), like server passwords and TOTP seeds
|
||||
- Wrong two-factor codes count toward the account lockout like wrong passwords (with the password known, only the per-IP block limited guessing the 6-digit code)
|
||||
- The update repository URL must be `https://` — updates pull, build and run that code as root on the host
|
||||
- Session tokens and console handles in WebSocket query strings are no longer written to the logs (uvicorn and both nginx configs)
|
||||
- **The session JWT is no longer put in WebSocket URLs** (where it ended up in proxy logs and browser history) — the panel fetches a single-use ticket (`POST /api/auth/ws-ticket`, valid 30 s, bound to the user and session) before every WebSocket (re)connect and passes `?ticket=`. **`?token=<jwt>` is no longer accepted** — WebSocket clients outside the panel must fetch a ticket first; browser tabs opened before the update need a reload
|
||||
- Guest password change rejects user names and passwords that would inject extra `chpasswd` lines
|
||||
- Console WebSocket bridges verify the Proxmox TLS certificate when the server is set to verify it (they always skipped verification, while the root password is sent there)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Session validation writes to the DB at most once a minute per session instead of on every request; security settings cache is per key
|
||||
- Task lists and WebSocket task updates no longer carry full Proxmox task logs; script execution lists no longer carry outputs (up to 200 KB each)
|
||||
- VM cache sync: one query for all cached rows instead of one per guest every 10 s, `last_sync_at` written at most once a minute, one Proxmox client per server
|
||||
- Resource monitoring reuses the VM list instead of one extra API call per running VM per minute
|
||||
- Request audit logging and the WebSocket initial snapshot run off the event loop; uploads are written to disk in a worker thread
|
||||
- Scheduler jobs coalesce instead of piling up; the first VM cache sync no longer blocks application startup
|
||||
- Metrics broadcaster tasks are kept referenced (they could be garbage-collected and silently stop)
|
||||
- **Translations are loaded per language** — `ru.json` and `en.json` (~210 KB) were bundled into the entry for everyone; now only the active language is fetched, and switching languages loads the other on demand. Russian sessions do not download English as a fallback: `ru.json` was completed (18 strings of the node administration page were shown in English) and CI checks that it covers every key of `en.json`
|
||||
- Login page backgrounds: 2.4 MB PNG → 36 KB WebP; `index.html` is served with `Cache-Control: no-cache`
|
||||
- Dashboard task counters refresh every 30 s instead of pulling 500 tasks every 5 s; finished task logs stop polling
|
||||
- Migration 45: indexes for per-user task polling
|
||||
|
||||
### 🛠 Process
|
||||
|
||||
- Production deploy waits for the backend tests and the frontend build (it ran on every push to `main` regardless); deploys no longer overlap
|
||||
- New `Frontend Checks` workflow (build, typecheck and lint reported); `upload-artifact` bumped to v4 (v3 is shut down)
|
||||
- `.dockerignore` excludes `.venv`, `.env` and `graphify-out` from build contexts
|
||||
- The backend image no longer installs `docker.io` (unused: self-update runs on the host via a trigger file, no Docker socket is mounted)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- WebSocket client keeps reconnecting with backoff (it gave up after ~30 s, e.g. during a self-update) and reconnects under the new identity after login, logout and impersonation
|
||||
- A failed token refresh during a backend restart (5xx) no longer logs the user out
|
||||
- `HTTPException`s raised inside `try` blocks are no longer turned into 500 errors (28 endpoints)
|
||||
- A token refresh during impersonation no longer drops the impersonation link — the admin was stranded in the user's account (no way back) and further actions were not marked as impersonated
|
||||
- VNC, terminal, serial and node-shell consoles work on servers registered with a non-default API port (`:8006` was hard-coded)
|
||||
- App Store data files and catalog scripts larger than ~96 KB failed to install (`Argument list too long`: the whole file went into one shell argument); they are now written in chunks, temp files get random names
|
||||
- Deploys, App Store operations and script runs interrupted by a backend restart are marked failed at startup — they stayed `running` forever, inflating the active-task counter; the instances page stops polling a stored deploy task the backend returns 404/403 for (it polled every 2 s indefinitely)
|
||||
- Template list: two queries instead of two per template
|
||||
- Apps stuck in `installing` / `updating` / `deleting` after a restart are moved to their failure state (retry is available; `installing` also held a quota slot)
|
||||
- Metrics history reads column tuples instead of ORM objects (month windows are tens of thousands of rows) and loads only the selected NIC
|
||||
- The console, dashboard and instance detail pages are loaded on demand: xterm (with its CSS) and recharts (~0.4 MB) were in the entry bundle for every user, including tenants without a dashboard
|
||||
- Image upload copies the spooled file in one worker call instead of two thread hops per 4 MB chunk
|
||||
- Scheduled backups no longer block the event loop while talking to Proxmox; a job with malformed VMIDs is marked failed instead of hanging in `running`
|
||||
- `backend/.env` keys unknown to the settings model (e.g. `ALLOWED_HOSTS`) no longer prevent startup
|
||||
|
||||
- **`guest-exec` is unblocked in the Rocky Linux, AlmaLinux, CentOS Stream and Fedora images** — the RHEL-family `qemu-guest-agent` package runs the agent with an allow-list that omits `guest-exec`, `guest-exec-status` and `guest-file-*` (`FILTER_RPC_ARGS` in `/etc/sysconfig/qemu-ga`; older packages expressed the same thing through `BLOCK_RPCS`/`BLACKLIST_RPC`). Because of it, script execution, extra IP addresses and filesystem growth after a disk resize did not work in such VMs
|
||||
- **Five mirror images were rebuilt** — release `2026-08-22`, and the bundled catalog now points at it; Ubuntu and Debian are untouched and stay in the previous releases, so older panel versions keep working
|
||||
- **`patch-cloud-images.sh` clears the restriction while building** (turn it off with `UNBLOCK_RPC=0`) and can build part of the catalog — `ONLY="rocky alma centos fedora"`. And when the panel hits the block, it spells out what to fix inside the guest
|
||||
|
||||
@@ -6,8 +6,82 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🔒 Безопасность
|
||||
|
||||
- **Пользователь больше не может управлять чужими гостями** — 28 эндпоинтов VM/LXC (выполнение команд через guest agent и `pct exec`, чтение/изменение конфигурации, диски, cloud-init, разблокировка, HA, метрики, RRD) и эндпоинты фаервола гостя проверяли только право, но не владение. Любой пользователь с `vm:console` (входит в стандартную роль `user`) мог выполнять команды в любой ВМ. Так же ограничены создание/восстановление/список бэкапов и логи задач Proxmox
|
||||
- **Серверные эндпоинты учитывают назначение серверов** — syslog/службы/APT нод, фаервол кластера, диски/ZFS, сети, пулы, SDN, бэкапы и шаблоны серверов, не назначенных пользователю (напрямую или через рабочую область), теперь отдают 404
|
||||
- **Закрыта эскалация привилегий в управлении пользователями** — с `user:create` можно было создать администратора (`is_admin` брался из запроса), с `user:update` — сменить пароль аккаунту с ролью admin или назначить себе роль admin, с `role:manage` — выдать права, которых нет у самого пользователя
|
||||
- **Каталог скриптов** — `script:execute` больше не позволяет запускать скрипты где угодно: скрипты на ноде требуют `node:manage`, на госте — доступа к гостю. Git-источники: `git clone` защищён от инъекции опций, проверяются URL/ветка/маска, symlink'и и пути вне клона игнорируются (репозиторий мог импортировать `/proc/self/environ`). Проверяются имена параметров и интерпретатор, временные файлы получают случайные имена
|
||||
- **WebSocket**: `/ws/tasks` проверяет сессию так же, как REST API (токен после выхода продолжал получать события); подписки на каналы и RRD-запросы авторизуются (можно было смотреть метрики чужих гостей); общий список инстансов фильтруется для каждого подписчика
|
||||
- **Ссылка сброса пароля не берётся из `Origin`/`Host` запроса** (reset poisoning) — новая настройка `PANEL_URL`, `deploy.sh` заполняет её при настройке домена
|
||||
- **Пароли больше не попадают в журнал аудита открытым текстом** — тела form-запросов (например `/api/auth/change-password`) сохранялись как есть; маскируются и коды 2FA, и приватные ключи
|
||||
- **Исправлен stored XSS через иконку группы шаблонов** (выводилась через `dangerouslySetInnerHTML`, редактируется с `template:manage`)
|
||||
- **SSRF**: `/api/images/resolve-filename` требует `template:manage` и не ходит на loopback/link-local адреса ни на одном шаге редиректа
|
||||
- **IP клиента больше нельзя подделать** через `X-Forwarded-For` для обхода блокировки перебора: заголовки прокси принимаются только от приватных адресов
|
||||
- **2FA**: TOTP-код одноразовый (RFC 6238), секрет хранится зашифрованным (миграция 44), время ответа на вход не выдаёт существование логина
|
||||
- **Сессии**: смена пароля завершает другие сессии (в обоих эндпоинтах; эндпоинт профиля теперь ещё и проверяет политику паролей), `stop-impersonate` требует живую сессию, `SECRET_KEY`-заглушка заменяется сгенерированным ключом, сохранённым в томе данных
|
||||
- **SSH-ключи нод закрепляются при первом подключении (TOFU)** — панель принимала любой ключ, предъявленный нодой (`WarningPolicy`), поэтому перехвативший соединение мог выдать себя за ноду и получить пароль root. Теперь первый увиденный ключ сохраняется в `/app/data/ssh_known_hosts` (`SSH_KNOWN_HOSTS_FILE`), другой ключ потом отклоняется с понятной ошибкой, а новая кнопка **«Сбросить SSH-ключ хоста»** на карточке сервера (`DELETE /proxmox/api/servers/{id}/ssh-host-key`, право `server:update`) снимает закрепление после переустановки ноды. `SSH_REJECT_UNKNOWN_HOSTS=true` по-прежнему включает строгий режим
|
||||
- **Квоты** действуют для клонов и установки приложений App Store; установка проверяет storage/bridge/ноду и доступ к серверу
|
||||
- Уведомления о ВМ и ресурсах уходят только владельцу и администраторам (раньше — всем пользователям); WebSocket-рассылки больше не содержат имён чужих гостей и e-mail нового владельца
|
||||
- **Консоли больше не выдают root-тикет Proxmox** — при открытии VNC-консоли или терминала в браузер уходил `PVEAuthCookie` учётной записи панели в Proxmox (обычно `root@pam`) и IP ноды: любой тенант с `vm:console` на своём госте получал полный доступ к API кластера на два часа. Теперь тикет остаётся на бэкенде, браузер получает одноразовый ключ, привязанный к пользователю и гостю
|
||||
- **Массовые операции проверяют каждого гостя** — `/api/bulk-operation` проверял только `vm:manage`, а роль с `vm:*` проходила эту проверку, не считаясь глобальной в остальных местах, и могла остановить или удалить чужие гости. Теперь для каждого элемента нужно право самого действия (как у одиночных эндпоинтов: `vm:start`, `vm:stop`, `vm:delete`, `vm:migrate`/`lxc:migrate`) и доступ к гостю — владельцы могут применять массовые действия к своим гостям
|
||||
- **Бэкапы ограничены владельцем гостя** — тенант мог восстановить чужой архив в свой гость (получив его диски), восстановить в новый неучтённый VMID, принудительно перезаписать чужие гости массовым восстановлением, удалить чужие файлы бэкапов и читать любой лог задачи через `/api/backups/task`. Задания резервного копирования проверяют VMID по владельцу (с очисткой `keep_last` чужой VMID стирал бэкапы того гостя), проверяют cron-выражение, а `toggle` / `run-now` — владельца задания
|
||||
- **Инъекция команд в золотом шаблоне App Store** — `POST /api/appstore/golden-template` подставлял `template_storage`, `rootfs_storage` и `bridge` из запроса в root-команды на ноде (`app:manage` → root на ноде); теперь значения проверяются, а сервер должен быть доступен пользователю
|
||||
- **Деплой из шаблона учитывает назначение серверов** — в запросе указан шаблон, а не сервер, поэтому проверка на уровне роутера не срабатывала и тенант мог развернуть ВМ на любом сервере; в списке шаблонов тенант видит только шаблоны своих серверов
|
||||
- Логотипы App Store (часто SVG из сторонних каталогов) отдаются с изолирующим CSP — при прямом открытии скрипт внутри выполнялся на origin панели рядом с токеном сессии; глобальный middleware заголовков больше не перезаписывает более строгий CSP эндпоинта
|
||||
- «Открыть приложение» после установки из App Store открывает его без `window.opener` (стороннее приложение могло перенаправить вкладку панели)
|
||||
- **Создание LXC / ISO-ВМ позволяло читать данные чужих гостей** — `ostemplate` принимал архив vzdump (`pct create` из бэкапа — это восстановление, и тенант получал файловую систему чужого гостя), а `iso` / `extra_iso` — любой том (чужой диск подключался как CD-ROM); теперь принимаются только `<storage>:vztmpl/…` и `<storage>:iso/…`. Оба эндпоинта учитывают назначение серверов (сервер приходит в теле запроса), а привилегированные контейнеры (root в них — root на ноде) требуют `vm:manage`
|
||||
- **Параллельные создания больше не обходят `max_instances`** — запись об инстансе появляется только после клонирования, поэтому N одновременных деплоев проходили проверку; теперь учитываются деплои, клоны, ISO-ВМ и установки App Store в очереди и в работе
|
||||
- **Имена нод и другие идентификаторы проверяются до обращения к Proxmox API** — `node`, сегменты пути, id хранилищ, id томов бэкапов и UPID из запросов подставлялись в пути Proxmox API без экранирования, поэтому специально составленное значение могло направить запрос, авторизованный для одного гостя, к другому ресурсу. Все маршруты `/proxmox` (HTTP и WebSocket) теперь отклоняют такие значения, а тела запросов проверяют имена нод
|
||||
- **Открытые консоли закрываются, когда доступ пропадает** — доступ к WebSocket проверялся только при подключении, поэтому VNC/терминал/serial-консоль или root-консоль ноды оставались рабочими после выхода, смены пароля, завершения сессии, блокировки аккаунта или отзыва права / владения гостем. Теперь доступ перепроверяется каждые 30 с на всех мостах консолей и в `/ws/tasks`
|
||||
- Сама страница панели отдаётся с `frame-ancestors 'self'` / `X-Frame-Options`, `nosniff` и политикой referrer: заголовки безопасности ставились только на ответы API (заголовки внешнего nginx не наследовались SSL-блоком server), и любой сайт мог встроить панель во фрейм
|
||||
- Без SSL-профиля `compose.yml` публиковал API на всех интерфейсах хоста (порт 8000) в обход nginx; теперь он привязан к `127.0.0.1` (UI, как и раньше, ходит через nginx фронтенда)
|
||||
- Пароль SMTP и токен Telegram-бота хранятся в зашифрованном виде (миграция 46 шифрует существующие значения), как пароли серверов и секреты TOTP
|
||||
- Неверные коды 2FA учитываются в блокировке аккаунта так же, как неверный пароль (при известном пароле подбор 6-значного кода ограничивала только блокировка по IP)
|
||||
- URL репозитория обновлений должен быть `https://` — обновление скачивает, собирает и запускает этот код под root на хосте
|
||||
- Токены сессии и ключи консолей из query-строк WebSocket больше не попадают в логи (uvicorn и оба конфига nginx)
|
||||
- **JWT сессии больше не передаётся в URL WebSocket** (откуда попадал в логи прокси и историю браузера) — перед каждым (пере)подключением панель получает одноразовый тикет (`POST /api/auth/ws-ticket`, действует 30 с, привязан к пользователю и сессии) и передаёт `?ticket=`. **`?token=<jwt>` больше не принимается** — сторонние WebSocket-клиенты должны сначала получить тикет; вкладки, открытые до обновления, нужно перезагрузить
|
||||
- Смена пароля гостя отклоняет имена и пароли, которые добавили бы лишние строки в `chpasswd`
|
||||
- WebSocket-мосты консолей проверяют TLS-сертификат Proxmox, если для сервера включена проверка (раньше всегда отключали, хотя туда отправляется пароль root)
|
||||
|
||||
### ⚡ Производительность
|
||||
|
||||
- Проверка сессии пишет в БД не чаще раза в минуту вместо каждого запроса; кэш настроек безопасности — по ключам
|
||||
- Списки задач и WebSocket-обновления больше не тащат полные логи задач Proxmox; список запусков скриптов — их вывод (до 200 КБ каждый)
|
||||
- Синхронизация кэша VM: один запрос на все строки вместо запроса на каждого гостя раз в 10 с, `last_sync_at` не чаще раза в минуту, один клиент Proxmox на сервер
|
||||
- Мониторинг ресурсов использует уже полученный список VM вместо отдельного запроса к API на каждую запущенную VM раз в минуту
|
||||
- Аудит запросов и начальный снимок WebSocket выполняются вне event loop; загружаемые файлы пишутся на диск в отдельном потоке
|
||||
- Задачи планировщика не копятся при отставании; первая синхронизация кэша VM больше не блокирует запуск приложения
|
||||
- На фоновые задачи рассылки метрик держатся ссылки (их мог собрать GC, и поток метрик молча останавливался)
|
||||
- **Переводы загружаются по языкам** — `ru.json` и `en.json` (~210 КБ) входили в основной бандл у всех; теперь загружается только активный язык, второй — при переключении. Русская сессия не тянет английский как запасной: `ru.json` дополнен (18 строк страницы администрирования ноды показывались по-английски), а CI проверяет, что в нём есть все ключи `en.json`
|
||||
- Фон страницы входа: 2,4 МБ PNG → 36 КБ WebP; `index.html` отдаётся с `Cache-Control: no-cache`
|
||||
- Счётчики задач на дашборде обновляются раз в 30 с вместо выгрузки 500 задач каждые 5 с; лог завершённой задачи больше не опрашивается
|
||||
- Миграция 45: индексы для опроса задач по пользователю
|
||||
|
||||
### 🛠 Процессы
|
||||
|
||||
- Деплой в прод ждёт тестов бэкенда и сборки фронтенда (раньше запускался на каждый push в `main` без проверок); деплои не пересекаются
|
||||
- Новый workflow `Frontend Checks` (сборка; typecheck и lint — в отчёт); `upload-artifact` обновлён до v4 (v3 отключён GitHub)
|
||||
- `.dockerignore` исключает `.venv`, `.env` и `graphify-out` из контекста сборки
|
||||
- Образ бэкенда больше не ставит `docker.io` (не использовался: самообновление выполняет хостовый сервис по файлу-триггеру, сокет Docker не монтируется)
|
||||
|
||||
### 🐛 Исправления
|
||||
|
||||
- WebSocket-клиент переподключается с нарастающей задержкой (раньше сдавался через ~30 с, например при самообновлении) и после входа, выхода и имперсонации переподключается под новой учётной записью
|
||||
- Неудачное обновление токена во время перезапуска бэкенда (5xx) больше не разлогинивает пользователя
|
||||
- `HTTPException`, выброшенные внутри `try`, больше не превращаются в 500 (28 эндпоинтов)
|
||||
- Обновление токена во время имперсонации больше не теряет связь с админом — администратор застревал в аккаунте пользователя (без возврата), а дальнейшие действия не помечались как имперсонация
|
||||
- VNC, терминал, serial и консоль ноды работают на серверах с нестандартным портом API (`:8006` был зашит в код)
|
||||
- Файлы данных App Store и скрипты каталога больше ~96 КБ не устанавливались (`Argument list too long`: весь файл шёл одним аргументом shell); теперь пишутся частями, временные файлы получают случайные имена
|
||||
- Деплои, операции App Store и запуски скриптов, прерванные рестартом бэкенда, при старте помечаются failed — раньше навсегда оставались `running` и завышали счётчик активных задач; страница инстансов перестаёт опрашивать сохранённую задачу деплоя, на которую бэкенд отвечает 404/403 (опрашивала каждые 2 с бесконечно)
|
||||
- Список шаблонов: два запроса вместо двух на каждый шаблон
|
||||
- Приложения, зависшие в `installing` / `updating` / `deleting` после рестарта, переводятся в состояние ошибки (доступен повтор; `installing` к тому же занимал слот квоты)
|
||||
- История метрик читает кортежи столбцов вместо ORM-объектов (окно за месяц — десятки тысяч строк) и загружает только выбранный интерфейс
|
||||
- Страницы консоли, дашборда и инстанса загружаются по требованию: xterm (со стилями) и recharts (~0,4 МБ) входили в основной бандл у всех, включая тенантов без дашборда
|
||||
- Загрузка образа копирует буферизованный файл одним вызовом в пуле потоков вместо двух переключений на каждые 4 МБ
|
||||
- Резервное копирование по расписанию больше не блокирует event loop при обращении к Proxmox; задание с некорректными VMID помечается failed, а не зависает в `running`
|
||||
- Неизвестные модели настроек ключи в `backend/.env` (например `ALLOWED_HOSTS`) больше не мешают запуску
|
||||
|
||||
- **В образах Rocky Linux, AlmaLinux, CentOS Stream и Fedora разблокирован `guest-exec`** — пакет `qemu-guest-agent` в RHEL-семействе запускает агента с allow-list, в котором нет `guest-exec`, `guest-exec-status` и `guest-file-*` (`FILTER_RPC_ARGS` в `/etc/sysconfig/qemu-ga`; в пакетах постарше то же самое задавалось через `BLOCK_RPCS`/`BLACKLIST_RPC`). Из-за этого в таких ВМ не работали выполнение скриптов, выдача дополнительных IP и расширение ФС после ресайза диска
|
||||
- **Пять образов зеркала пересобраны** — релиз `2026-08-22`, встроенный каталог переключён на него; Ubuntu и Debian не затронуты и остаются в прежних релизах, поэтому старые версии панели продолжают работать
|
||||
- **`patch-cloud-images.sh` снимает ограничение при сборке** (отключается через `UNBLOCK_RPC=0`) и умеет собирать часть каталога — `ONLY="rocky alma centos fedora"`. Панель же, наткнувшись на запрет, объясняет в тексте ошибки, что и где поправить в госте
|
||||
|
||||
@@ -11,9 +11,14 @@ build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Local secrets / generated artifacts — never part of the image build context
|
||||
.env
|
||||
graphify-out/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -16,6 +16,8 @@ DB_NAME=serverpanel
|
||||
DATABASE_URL=postgresql://serverpanel:serverpanel_secure_password@db:5432/serverpanel
|
||||
|
||||
# JWT Authentication
|
||||
# Must be replaced (>= 32 random chars, e.g. `openssl rand -hex 32`): the panel
|
||||
# refuses to start with this placeholder, since anyone could forge tokens with it.
|
||||
SECRET_KEY=your-very-secure-secret-key-change-this-in-production-minimum-32-chars
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
@@ -24,11 +26,19 @@ 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.
|
||||
# Node host keys are pinned on the first SSH connection (trust on first use);
|
||||
# a changed key is refused until it is reset on the server card ("Reset SSH
|
||||
# host key"), e.g. after a node reinstall. Pins live in SSH_KNOWN_HOSTS_FILE
|
||||
# (default: /app/data/ssh_known_hosts on the data volume).
|
||||
# SSH_KNOWN_HOSTS_FILE=/app/data/ssh_known_hosts
|
||||
# Strict mode: also refuse hosts that are neither pinned nor in known_hosts.
|
||||
SSH_REJECT_UNKNOWN_HOSTS=false
|
||||
|
||||
# Security
|
||||
# Public URL of the panel. Used for links in e-mails (password reset) — when
|
||||
# unset, the link is built from request headers, which a client can forge.
|
||||
# deploy.sh fills it in when a domain is configured.
|
||||
# PANEL_URL=https://panel.example.com
|
||||
# In production set to your actual domain(s), e.g.: https://panel.example.com
|
||||
CORS_ORIGINS=*
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
@@ -32,13 +32,14 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Только runtime-зависимости. docker.io даёт CLI `docker` для self-update через сокет.
|
||||
# Только runtime-зависимости. Docker CLI не нужен: self-update выполняет
|
||||
# хостовый сервис по файлу-триггеру (services/update_service.py), сокет Docker
|
||||
# в контейнер не монтируется.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
openssh-client \
|
||||
curl \
|
||||
git \
|
||||
docker.io \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -30,14 +30,16 @@ class InstallRequest(BaseModel):
|
||||
app_id: str
|
||||
name: str
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
form_answers: dict = {}
|
||||
# advanced (ТЗ 7.2, шаг 2)
|
||||
cores: int = 2
|
||||
memory: int = 2048 # МБ
|
||||
disk: int = 8 # ГБ
|
||||
storage: str = "local-lvm"
|
||||
bridge: str = "vmbr0"
|
||||
cores: int = Field(default=2, ge=1, le=512)
|
||||
memory: int = Field(default=2048, ge=64, le=4 * 1024 * 1024) # МБ
|
||||
disk: int = Field(default=8, ge=1, le=65536) # ГБ
|
||||
# storage/bridge are spliced into Proxmox option strings (rootfs=, net0=):
|
||||
# a ',' would let the caller append options such as firewall=0.
|
||||
storage: str = Field(default="local-lvm", pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
bridge: str = Field(default="vmbr0", pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
node: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
|
||||
ostemplate: Optional[str] = None # переопределение золотого шаблона
|
||||
# Кастомный рабочий порт приложения (переопределяет порт из каталога).
|
||||
# None — использовать порт по умолчанию из config.json приложения.
|
||||
@@ -64,9 +66,14 @@ def _get_owned(db: Session, app_id: int, user: User) -> InstalledApp:
|
||||
@router.post("/api/appstore/apps/install")
|
||||
def install_app(
|
||||
req: InstallRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("app:install")),
|
||||
):
|
||||
from ..proxmox._helpers import get_visible_server_ids
|
||||
visible = get_visible_server_ids(request, db, current_user)
|
||||
if visible is not None and req.server_id not in visible:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
try:
|
||||
result = engine.install(
|
||||
db, current_user,
|
||||
@@ -242,7 +249,8 @@ def app_logs(
|
||||
@router.post("/api/appstore/apps/reconcile")
|
||||
def reconcile(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(PermissionChecker("app:view")),
|
||||
# Walks every installed app and polls Proxmox for each — not for viewers.
|
||||
_: User = Depends(PermissionChecker("app:manage")),
|
||||
):
|
||||
return engine.reconcile(db)
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -105,7 +105,14 @@ def get_app_logo(
|
||||
# защита от path traversal: файл обязан лежать внутри data-каталога
|
||||
if base not in path.parents or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл логотипа отсутствует")
|
||||
return FileResponse(str(path))
|
||||
# Logos come from third-party catalog repos and are often SVG, served from
|
||||
# the panel's origin: opened directly, a script inside would run next to the
|
||||
# session token. The sandbox CSP keeps <img> rendering and blocks scripts.
|
||||
return FileResponse(str(path), headers={
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/appstore/catalog/sync")
|
||||
@@ -129,22 +136,31 @@ async def trigger_sync(
|
||||
|
||||
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
|
||||
|
||||
_PVE_ID = r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
|
||||
|
||||
|
||||
class GoldenTemplateRequest(BaseModel):
|
||||
# These go into root shell commands on the node (pct create / pvesm).
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
template_storage: str = "local" # dir-storage для итогового vztmpl
|
||||
rootfs_storage: str = "local-lvm" # storage для rootfs временного CT
|
||||
bridge: str = "vmbr0"
|
||||
node: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
|
||||
template_storage: str = Field(default="local", pattern=_PVE_ID) # dir-storage для итогового vztmpl
|
||||
rootfs_storage: str = Field(default="local-lvm", pattern=_PVE_ID) # storage для rootfs временного CT
|
||||
bridge: str = Field(default="vmbr0", pattern=_PVE_ID)
|
||||
force: bool = False # пересобрать, даже если шаблон уже есть
|
||||
|
||||
|
||||
@router.post("/api/appstore/golden-template")
|
||||
def build_golden_template(
|
||||
req: GoldenTemplateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("app:manage")),
|
||||
):
|
||||
"""Собрать золотой LXC-шаблон на ноде (фоновая операция). Прогресс — по WebSocket."""
|
||||
from ..proxmox._helpers import get_visible_server_ids
|
||||
visible = get_visible_server_ids(request, db, current_user)
|
||||
if visible is not None and req.server_id not in visible:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
try:
|
||||
result = engine.prepare_golden_template(
|
||||
db, current_user, server_id=req.server_id, node=req.node,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import html
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, BackgroundTasks
|
||||
@@ -25,6 +26,7 @@ from ..auth import (
|
||||
get_current_active_admin,
|
||||
get_client_ip,
|
||||
get_current_session_token,
|
||||
issue_ws_ticket,
|
||||
security,
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
@@ -34,6 +36,14 @@ from ..services.security_service import SecurityService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DUMMY_HASH: list = []
|
||||
|
||||
|
||||
def _dummy_password_hash() -> str:
|
||||
if not _DUMMY_HASH:
|
||||
_DUMMY_HASH.append(get_password_hash(secrets.token_urlsafe(16)))
|
||||
return _DUMMY_HASH[0]
|
||||
|
||||
|
||||
@router.post("/api/auth/login", response_model=Token)
|
||||
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
@@ -73,6 +83,9 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
user = db.query(User).filter(User.username == login_data.username).first()
|
||||
|
||||
if not user:
|
||||
# Spend the same bcrypt time as a real check, so response timing does not
|
||||
# reveal which usernames exist.
|
||||
verify_password(login_data.password, _dummy_password_hash())
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "User not found", user_agent
|
||||
)
|
||||
@@ -172,7 +185,7 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
return {"two_factor_required": True, "token_type": "bearer"}
|
||||
|
||||
from ..services import two_factor_service as tfa
|
||||
code_ok = tfa.verify_totp(user.two_factor_secret, login_data.code)
|
||||
code_ok = tfa.verify_totp(user.two_factor_secret, login_data.code, replay_key=user.id)
|
||||
if not code_ok:
|
||||
backup = list(user.two_factor_backup_codes or [])
|
||||
if tfa.consume_backup_code(login_data.code, backup):
|
||||
@@ -184,6 +197,14 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
SecurityService.record_login_attempt(
|
||||
db, client_ip, login_data.username, False, "Invalid 2FA code", user_agent
|
||||
)
|
||||
# Count it against the account like a wrong password: with the
|
||||
# password known, only the per-IP block limited guessing the
|
||||
# 6-digit code, and it does not stop an attacker using many IPs.
|
||||
if SecurityService.increment_failed_attempts(db, user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account locked due to too many failed attempts",
|
||||
)
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
action="login_failed",
|
||||
@@ -254,13 +275,21 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
|
||||
def refresh_token(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session_token: str = Depends(get_current_session_token),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Refresh JWT access token without re-login (extends session)"""
|
||||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||||
access_token_expires = timedelta(minutes=session_timeout)
|
||||
claims = {"sub": current_user.username}
|
||||
# Keep the impersonation link: dropping it stranded the admin in the
|
||||
# user's account (no way back via stop-impersonate) and unmarked the
|
||||
# session as impersonated in the audit trail.
|
||||
impersonator = decode_access_token(credentials.credentials).get("impersonator")
|
||||
if impersonator:
|
||||
claims["impersonator"] = impersonator
|
||||
new_token = create_access_token(
|
||||
data={"sub": current_user.username},
|
||||
data=claims,
|
||||
session_token=session_token,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
@@ -270,6 +299,19 @@ def refresh_token(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/auth/ws-ticket")
|
||||
def create_ws_ticket(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session_token: str = Depends(get_current_session_token),
|
||||
):
|
||||
"""Single-use, short-lived ticket for one WebSocket handshake (?ticket=).
|
||||
|
||||
Browsers cannot send headers on WebSocket requests; this keeps the session
|
||||
JWT out of WebSocket URLs.
|
||||
"""
|
||||
return {"ticket": issue_ws_ticket(current_user.id, session_token)}
|
||||
|
||||
|
||||
@router.post("/api/auth/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
@@ -385,6 +427,17 @@ def stop_impersonating(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Not currently impersonating"
|
||||
)
|
||||
|
||||
# The impersonation token is exchanged for a fresh admin token, so it must
|
||||
# still be backed by a live session — otherwise a leaked token keeps being
|
||||
# redeemable for admin access after logout/termination until its JWT expiry.
|
||||
impersonation_session = payload.get("session")
|
||||
if not impersonation_session or not SecurityService.validate_session(db, impersonation_session):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired or invalid. Please login again.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
admin = db.query(User).filter(User.username == impersonator_username).first()
|
||||
if not admin or not admin.is_active or not _user_is_admin(admin):
|
||||
raise HTTPException(
|
||||
@@ -393,9 +446,7 @@ def stop_impersonating(
|
||||
)
|
||||
|
||||
# Terminate the impersonation session so it does not linger.
|
||||
impersonation_session = payload.get("session")
|
||||
if impersonation_session:
|
||||
SecurityService.terminate_session(db, impersonation_session)
|
||||
SecurityService.terminate_session(db, impersonation_session)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent", "")[:500]
|
||||
@@ -556,7 +607,7 @@ def two_factor_disable(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect password")
|
||||
|
||||
if current_user.two_factor_enabled:
|
||||
code_ok = tfa.verify_totp(current_user.two_factor_secret, data.code or "")
|
||||
code_ok = tfa.verify_totp(current_user.two_factor_secret, data.code or "", replay_key=current_user.id)
|
||||
if not code_ok:
|
||||
backup = list(current_user.two_factor_backup_codes or [])
|
||||
code_ok = tfa.consume_backup_code(data.code or "", backup)
|
||||
@@ -713,6 +764,7 @@ def change_password(
|
||||
new_password: str = Form(...),
|
||||
request: Request = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session_token: str = Depends(get_current_session_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -750,6 +802,10 @@ def change_password(
|
||||
current_user.last_password_change = utcnow()
|
||||
current_user.require_password_change = False
|
||||
db.commit()
|
||||
|
||||
# A changed password must lock out whoever may have used the old one:
|
||||
# end every other session, keep the one that made the change.
|
||||
SecurityService.terminate_all_user_sessions(db, current_user.id, except_token=session_token)
|
||||
|
||||
LoggingService.log_auth(
|
||||
db=db,
|
||||
@@ -774,6 +830,27 @@ _FORGOT_PASSWORD_GENERIC = {
|
||||
}
|
||||
|
||||
|
||||
def _reset_link_origin(request: Request) -> str:
|
||||
"""Origin the password-reset link points to.
|
||||
|
||||
The request's Origin/Host headers are attacker-controlled: anyone can ask
|
||||
for a reset of the victim's address with ``Origin: https://evil.example``
|
||||
and the emailed link would hand the one-time token to that host (reset
|
||||
poisoning). Prefer the configured PANEL_URL, then a single explicit
|
||||
CORS origin; the request origin is only a last-resort fallback.
|
||||
"""
|
||||
if settings.PANEL_URL:
|
||||
return settings.PANEL_URL.rstrip("/")
|
||||
cors = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
if len(cors) == 1 and cors[0] != "*":
|
||||
return cors[0].rstrip("/")
|
||||
logger.warning(
|
||||
"PANEL_URL is not set: password-reset links are built from request headers. "
|
||||
"Set PANEL_URL (e.g. https://panel.example.com) to prevent reset-link poisoning."
|
||||
)
|
||||
return (request.headers.get("origin") or str(request.base_url)).rstrip("/")
|
||||
|
||||
|
||||
def _hash_reset_token(raw_token: str) -> str:
|
||||
"""Hash a raw reset token for storage/lookup (only the hash is persisted)."""
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
@@ -813,16 +890,18 @@ async def _send_reset_email(to_email: str, display_name: str, lang: str, link: s
|
||||
btn = "Reset password"
|
||||
note = f"This link is valid for {expire_minutes} minutes. If you didn't request a reset, ignore this email."
|
||||
|
||||
# full_name is user-editable — escape everything interpolated into HTML.
|
||||
esc = html.escape
|
||||
html_body = f"""
|
||||
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;color:#1a1a1a">
|
||||
<h2 style="font-size:20px;margin:0 0 16px">{panel}</h2>
|
||||
<p style="font-size:15px;line-height:1.5;margin:0 0 20px">{body.splitlines()[0]}</p>
|
||||
<h2 style="font-size:20px;margin:0 0 16px">{esc(panel)}</h2>
|
||||
<p style="font-size:15px;line-height:1.5;margin:0 0 20px">{esc(body.splitlines()[0])}</p>
|
||||
<p style="margin:0 0 24px">
|
||||
<a href="{link}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;
|
||||
<a href="{esc(link)}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;
|
||||
padding:10px 20px;border-radius:8px;font-size:15px;font-weight:600">{btn}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#666;line-height:1.5;margin:0 0 8px">{note}</p>
|
||||
<p style="font-size:12px;color:#999;word-break:break-all;margin:0">{link}</p>
|
||||
<p style="font-size:12px;color:#999;word-break:break-all;margin:0">{esc(link)}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -894,9 +973,7 @@ def forgot_password(
|
||||
db.add(reset)
|
||||
db.commit()
|
||||
|
||||
# Build the reset link from the request origin (SPA and API share an origin)
|
||||
origin = request.headers.get("origin") or str(request.base_url).rstrip("/")
|
||||
link = f"{origin}/reset-password?token={raw_token}"
|
||||
link = f"{_reset_link_origin(request)}/reset-password?token={raw_token}"
|
||||
|
||||
background.add_task(
|
||||
_send_reset_email,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from .servers import router as servers_router
|
||||
from .sdn import router as sdn_router
|
||||
from .snapshots import router as snapshots_router
|
||||
@@ -16,9 +16,11 @@ from .disks import router as disks_router
|
||||
from .access import router as access_router
|
||||
from .node_admin import router as node_admin_router
|
||||
from .topology import router as topology_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance, validate_pve_identifiers
|
||||
|
||||
router = APIRouter()
|
||||
# Applies to every included route, WebSockets too: path segments / node names
|
||||
# go into Proxmox API URLs (see validate_pve_identifiers).
|
||||
router = APIRouter(dependencies=[Depends(validate_pve_identifiers)])
|
||||
router.include_router(servers_router)
|
||||
router.include_router(sdn_router)
|
||||
router.include_router(snapshots_router)
|
||||
|
||||
@@ -3,9 +3,16 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List, Optional
|
||||
from typing import Annotated, List, Optional
|
||||
from pydantic import Field
|
||||
from starlette.requests import HTTPConnection
|
||||
from fastapi import WebSocketException
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import asyncio
|
||||
import httpx
|
||||
import websockets
|
||||
@@ -108,8 +115,62 @@ def can_view_all_instances(current_user: User) -> bool:
|
||||
role = current_user.role
|
||||
if role and role.name == 'admin':
|
||||
return True
|
||||
perms = (role.permissions if role else None) or {}
|
||||
return bool(perms.get('vm:manage', False))
|
||||
# Same evaluation as PermissionChecker("vm:manage") — a raw dict lookup
|
||||
# missed ``vm:*``, so such a role passed vm:manage-gated endpoints while
|
||||
# being treated as a tenant everywhere else.
|
||||
return check_permission(current_user, 'vm:manage')
|
||||
|
||||
|
||||
# Stand-in request without headers: visibility checks that must not be
|
||||
# narrowed by the client-chosen X-Active-Workspace header.
|
||||
_NO_WORKSPACE_HEADER = SimpleNamespace(headers={})
|
||||
|
||||
|
||||
def pve_authority(server: ProxmoxServer) -> str:
|
||||
"""``host:port`` of the server's Proxmox API (as ProxmoxClient.from_server).
|
||||
|
||||
Console endpoints built their URLs with a hard-coded ``:8006`` and failed
|
||||
on servers registered with another port.
|
||||
"""
|
||||
host = server.ip_address or getattr(server, "hostname", "") or ""
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]" # bare IPv6
|
||||
return f"{host}:{server.port or 8006}"
|
||||
|
||||
|
||||
# Console sessions need a PVE auth ticket (PVEAuthCookie) of the panel's
|
||||
# Proxmox account — usually root@pam. It used to be returned to the browser,
|
||||
# handing every tenant with vm:console a root ticket for the whole cluster.
|
||||
# Now the ticket stays here and the browser gets an opaque, single-use handle
|
||||
# bound to the user and guest. In-memory: the backend runs one uvicorn worker.
|
||||
_PVE_TICKET_TTL = 120.0
|
||||
_pve_tickets: dict = {}
|
||||
_pve_tickets_lock = threading.Lock()
|
||||
|
||||
|
||||
def stash_pve_ticket(ticket: Optional[str], user_id: int, server_id: int, vmid: int) -> Optional[str]:
|
||||
if not ticket:
|
||||
return None
|
||||
handle = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
with _pve_tickets_lock:
|
||||
for key in [k for k, v in _pve_tickets.items() if v[4] < now]:
|
||||
del _pve_tickets[key]
|
||||
_pve_tickets[handle] = (ticket, user_id, server_id, vmid, now + _PVE_TICKET_TTL)
|
||||
return handle
|
||||
|
||||
|
||||
def claim_pve_ticket(handle: Optional[str], user_id: int, server_id: int, vmid: int) -> Optional[str]:
|
||||
"""Redeem a handle from :func:`stash_pve_ticket` (once), or None."""
|
||||
if not handle:
|
||||
return None
|
||||
with _pve_tickets_lock:
|
||||
entry = _pve_tickets.pop(handle, None)
|
||||
if not entry or entry[4] < time.monotonic():
|
||||
return None
|
||||
if entry[1:4] != (user_id, server_id, vmid):
|
||||
return None
|
||||
return entry[0]
|
||||
|
||||
|
||||
def get_visible_server_ids(
|
||||
@@ -164,6 +225,43 @@ def get_visible_server_ids(
|
||||
return effective_ids
|
||||
|
||||
|
||||
def enforce_server_visibility(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Router dependency: 404 for {server_id} routes outside the user's servers.
|
||||
|
||||
Permission checks alone (node:view, firewall:view, storage:view … are in
|
||||
the stock ``user`` role) let a tenant read node syslog, firewall rules,
|
||||
disks or pools of every server in the panel. Privileged users are not
|
||||
narrowed; the active-workspace header is ignored here (it is a UI
|
||||
filter, not an access boundary). Guests the user owns stay reachable
|
||||
even on a server that is not assigned to them.
|
||||
"""
|
||||
raw = request.path_params.get("server_id")
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
server_id = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if current_user.is_admin or (
|
||||
current_user.role and current_user.role.name in ('admin', 'moderator')
|
||||
):
|
||||
return
|
||||
vmid = request.path_params.get("vmid")
|
||||
if vmid is not None:
|
||||
try:
|
||||
if check_vm_access(db, current_user, server_id, int(vmid)):
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
visible = get_visible_server_ids(_NO_WORKSPACE_HEADER, db, current_user)
|
||||
if visible is not None and server_id not in visible:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
|
||||
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
|
||||
"""
|
||||
Check if user has access to a specific VM instance.
|
||||
@@ -185,6 +283,90 @@ def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int)
|
||||
return bool(instance and instance.owner_id == current_user.id)
|
||||
|
||||
|
||||
def require_server_visible(db: Session, current_user: User, server_id: int) -> None:
|
||||
"""404 unless the server is visible to the user.
|
||||
|
||||
For endpoints taking ``server_id`` in the request BODY: the router-level
|
||||
:func:`enforce_server_visibility` only sees path parameters.
|
||||
"""
|
||||
visible = get_visible_server_ids(_NO_WORKSPACE_HEADER, db, current_user)
|
||||
if visible is not None and server_id not in visible:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
|
||||
# Volume ids a create request may reference. Anything else lets the request
|
||||
# point at another guest's disk or backup (pct create from a vzdump archive is
|
||||
# a restore; a disk volume can be attached as a CD-ROM).
|
||||
ISO_VOLID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*:iso/[^/\s]+$")
|
||||
VZTMPL_VOLID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*:vztmpl/[^/\s]+$")
|
||||
|
||||
|
||||
# ── Identifiers that end up in Proxmox API URL paths ──────────────────────
|
||||
# proxmoxer splits path arguments on '/' and joins them unescaped, so a node,
|
||||
# storage or other segment carrying '/', '#' or '?' rewrites the request path:
|
||||
# an access check made for one guest would act on another resource.
|
||||
NODE_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$"
|
||||
PVE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$"
|
||||
_NODE_RE = re.compile(NODE_PATTERN)
|
||||
_PVE_ID_RE = re.compile(PVE_ID_PATTERN)
|
||||
_UPID_RE = re.compile(r"^UPID:[A-Za-z0-9.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:[\w.-]*:[^:/?#\s]*:[^:/?#\s]+:?$")
|
||||
_UNSAFE_SEGMENT_RE = re.compile(r"[/#?\\\x00-\x1f]")
|
||||
# Path parameters declared with the :path converter (or CIDRs) may hold '/'.
|
||||
_SLASH_PATH_PARAMS = {"subnet_cidr"}
|
||||
_SUBNET_RE = re.compile(r"^[A-Za-z0-9_.:-]+(/\d{1,3})?$")
|
||||
_NODE_QUERY_KEYS = ("node", "target_node", "source_node")
|
||||
|
||||
PveNode = Annotated[str, Field(pattern=NODE_PATTERN)]
|
||||
PveId = Annotated[str, Field(pattern=PVE_ID_PATTERN)]
|
||||
|
||||
|
||||
def require_node_name(value, field: str = "node") -> None:
|
||||
"""400 unless ``value`` is a plain node name (for dict/form bodies)."""
|
||||
if value is not None and value != "" and not (isinstance(value, str) and _NODE_RE.match(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {field}")
|
||||
|
||||
|
||||
def require_pve_id(value, field: str) -> None:
|
||||
"""400 unless ``value`` is a plain storage/pool/bridge-like id."""
|
||||
if value is not None and value != "" and not (isinstance(value, str) and _PVE_ID_RE.match(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {field}")
|
||||
|
||||
|
||||
def _identifier_error(conn: HTTPConnection) -> Optional[str]:
|
||||
for key, value in conn.path_params.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
if key == "upid":
|
||||
if not _UPID_RE.match(value):
|
||||
return key
|
||||
elif key in _SLASH_PATH_PARAMS:
|
||||
if not _SUBNET_RE.match(value):
|
||||
return key
|
||||
elif _UNSAFE_SEGMENT_RE.search(value):
|
||||
return key
|
||||
for key in _NODE_QUERY_KEYS:
|
||||
value = conn.query_params.get(key)
|
||||
if value and not _NODE_RE.match(value):
|
||||
return key
|
||||
storage = conn.query_params.get("storage") # e.g. backup listing: storage(...) path
|
||||
if storage and not _PVE_ID_RE.match(storage):
|
||||
return "storage"
|
||||
upid = conn.query_params.get("upid")
|
||||
if upid and not _UPID_RE.match(upid):
|
||||
return "upid"
|
||||
return None
|
||||
|
||||
|
||||
def validate_pve_identifiers(conn: HTTPConnection) -> None:
|
||||
"""Router dependency for every /proxmox route (HTTP and WebSocket)."""
|
||||
bad = _identifier_error(conn)
|
||||
if bad is None:
|
||||
return
|
||||
if conn.scope.get("type") == "websocket":
|
||||
raise WebSocketException(code=1008, reason=f"Invalid {bad}")
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {bad}")
|
||||
|
||||
|
||||
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
|
||||
"""
|
||||
Require user to have access to VM, raise 403 if not.
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
|
||||
@@ -5,6 +5,7 @@ Endpoints for managing Proxmox storages, backup files,
|
||||
creating backups (vzdump), restoring, and scheduled backup jobs.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -17,11 +18,66 @@ from ...db import get_db
|
||||
from ...models import BackupJob, ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ...logging_service import LoggingService
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ._helpers import (_get_proxmox_client, require_vm_access,
|
||||
can_view_all_instances, get_owned_vmids,
|
||||
require_node_name, require_pve_id)
|
||||
from ...proxmox import _run_in_executor
|
||||
from .tasks import ProxmoxTaskService
|
||||
from .tasks import ProxmoxTaskService, _require_upid_access
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# vzdump archives: "<storage>:backup/vzdump-qemu-101-2024_01_01-00_00_00.vma.zst";
|
||||
# PBS snapshots: "<storage>:backup/vm/101/2024-01-01T00:00:00Z".
|
||||
_ARCHIVE_VMID_RES = (
|
||||
re.compile(r"vzdump-(?:qemu|lxc|openvz)-(\d+)-"),
|
||||
re.compile(r":backup/(?:vm|ct)/(\d+)/"),
|
||||
)
|
||||
|
||||
|
||||
def archive_vmid(volid: str) -> Optional[int]:
|
||||
"""Guest VMID a backup archive belongs to, or None when it can't be told."""
|
||||
for rx in _ARCHIVE_VMID_RES:
|
||||
m = rx.search(volid or "")
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
_BACKUP_VOLID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*:backup/[^\s?#\\]+$")
|
||||
|
||||
|
||||
def _require_backup_volid(volid) -> None:
|
||||
"""A backup volid, with no '..' segments (it is split into URL path parts)."""
|
||||
if not (isinstance(volid, str) and _BACKUP_VOLID_RE.match(volid)) or ".." in volid.split("/"):
|
||||
raise HTTPException(status_code=400, detail="Invalid backup volume id")
|
||||
|
||||
|
||||
def _require_archive_access(db: Session, user: User, server_id: int, volid: str) -> None:
|
||||
"""Tenants may only touch backups of guests they own.
|
||||
|
||||
Restoring someone else's archive into one's own guest (or a new VMID)
|
||||
hands over that guest's disks; deleting it destroys their backup.
|
||||
"""
|
||||
if can_view_all_instances(user):
|
||||
return
|
||||
vmid = archive_vmid(volid)
|
||||
if vmid is None or vmid not in get_owned_vmids(db, user, server_id):
|
||||
raise HTTPException(status_code=403, detail="You do not have access to this backup")
|
||||
|
||||
|
||||
def _require_new_vmid_allowed(user: User) -> None:
|
||||
# A restore to a fresh VMID creates a guest the panel does not track
|
||||
# (no owner, outside quotas) — keep it to users with the global view.
|
||||
if not can_view_all_instances(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Restoring to a new VMID requires the vm:manage permission",
|
||||
)
|
||||
|
||||
|
||||
# ==================== Storage Management ====================
|
||||
@@ -48,6 +104,8 @@ def get_storages(
|
||||
s["nodes_status"] = statuses
|
||||
s["inactive_nodes"] = [n["node"] for n in statuses if not n["active"]]
|
||||
return JSONResponse(content={"storages": storages})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing storages for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -191,6 +249,12 @@ def list_backups(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
# Tenants only see backups of the guests they own.
|
||||
owned: Optional[set] = None
|
||||
if not can_view_all_instances(current_user):
|
||||
owned = get_owned_vmids(db, current_user, server_id)
|
||||
if vmid is not None and vmid not in owned:
|
||||
return JSONResponse(content={"backups": []})
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
@@ -216,6 +280,12 @@ def list_backups(
|
||||
logger.warning(f"Skipping node {n} when listing backups: {e}")
|
||||
continue
|
||||
for item in items:
|
||||
if owned is not None:
|
||||
try:
|
||||
if int(item.get("vmid")) not in owned:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
volid = item.get("volid")
|
||||
if volid and volid in seen:
|
||||
continue
|
||||
@@ -252,6 +322,11 @@ async def delete_backup(
|
||||
volid = data.get("volid")
|
||||
if not all([node, storage, volid]):
|
||||
raise HTTPException(status_code=400, detail="node, storage, volid are required")
|
||||
# All three become Proxmox API path segments.
|
||||
require_node_name(node)
|
||||
require_pve_id(storage, "storage")
|
||||
_require_backup_volid(volid)
|
||||
_require_archive_access(db, current_user, server.id, volid)
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
@@ -285,6 +360,7 @@ async def create_backup(
|
||||
node = data.get("node")
|
||||
vmid = data.get("vmid")
|
||||
storage = data.get("storage")
|
||||
require_node_name(node)
|
||||
|
||||
if not all([server_id, node, vmid, storage]):
|
||||
raise HTTPException(status_code=400, detail="server_id, node, vmid, storage are required")
|
||||
@@ -292,6 +368,11 @@ async def create_backup(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
try:
|
||||
vmid = int(vmid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="vmid must be an integer")
|
||||
require_vm_access(db, current_user, server.id, vmid)
|
||||
|
||||
mode = data.get("mode", "snapshot")
|
||||
compress = data.get("compress", "zstd")
|
||||
@@ -337,11 +418,14 @@ def get_task_status(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
_require_upid_access(db, current_user, server_id, upid)
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
status = client.get_task_status(node, upid)
|
||||
log = client.get_task_log(node, upid)
|
||||
return JSONResponse(content={"status": status, "log": log})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -360,6 +444,7 @@ async def restore_backup(
|
||||
node = data.get("node")
|
||||
vmid = data.get("vmid")
|
||||
archive = data.get("archive") # volume id
|
||||
require_node_name(node)
|
||||
# Target storage for the restored disks. Empty => "from backup configuration"
|
||||
# (disks go to the storage recorded in the backup). NOT the backup storage.
|
||||
storage = data.get("storage") or None
|
||||
@@ -377,6 +462,15 @@ async def restore_backup(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
try:
|
||||
vmid = int(vmid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="vmid must be an integer")
|
||||
# Restoring without new_vmid overwrites the original guest.
|
||||
require_vm_access(db, current_user, server.id, vmid)
|
||||
_require_archive_access(db, current_user, server.id, archive)
|
||||
if new_vmid:
|
||||
_require_new_vmid_allowed(current_user)
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
@@ -455,10 +549,29 @@ async def restore_backup_bulk(
|
||||
if mode not in ("in_place", "new_vmid"):
|
||||
raise HTTPException(status_code=400, detail="mode must be 'in_place' or 'new_vmid'")
|
||||
|
||||
if not isinstance(items, list) or len(items) > 200:
|
||||
raise HTTPException(status_code=400, detail="items must be a list of at most 200 entries")
|
||||
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
# Authorize every item before restoring any: an in-place restore is a
|
||||
# forced overwrite of the guest.
|
||||
if mode == "new_vmid":
|
||||
_require_new_vmid_allowed(current_user)
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
raise HTTPException(status_code=400, detail="items must be objects")
|
||||
require_node_name(it.get("node"))
|
||||
try:
|
||||
item_vmid = int(it.get("vmid"))
|
||||
except (TypeError, ValueError):
|
||||
continue # reported per item below
|
||||
if it.get("archive"):
|
||||
require_vm_access(db, current_user, server.id, item_vmid)
|
||||
_require_archive_access(db, current_user, server.id, it["archive"])
|
||||
|
||||
client = _get_proxmox_client(server)
|
||||
allocated: set = set()
|
||||
|
||||
@@ -734,6 +847,66 @@ async def delete_proxmox_native_job(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _is_job_admin(user: User) -> bool:
|
||||
return bool(user.is_admin or (user.role and user.role.name == "admin"))
|
||||
|
||||
|
||||
def _get_job_for_user(db: Session, user: User, job_id: int) -> BackupJob:
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not (_is_job_admin(user) or job.owner_id == user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return job
|
||||
|
||||
|
||||
def _clean_job_vmids(db: Session, user: User, server_id: int, vmids) -> list:
|
||||
"""Normalize ``vmids`` to ``["all"]`` or a list of ints the user may back up.
|
||||
|
||||
A job backs up with panel credentials and prunes older archives of every
|
||||
listed guest (keep_last), so a tenant listing someone else's VMID could
|
||||
wipe that guest's backups.
|
||||
"""
|
||||
if isinstance(vmids, str):
|
||||
vmids = [v.strip() for v in vmids.split(",") if v.strip()]
|
||||
if not isinstance(vmids, list) or not vmids:
|
||||
raise HTTPException(status_code=400, detail="vmids must be a non-empty list or 'all'")
|
||||
if vmids == ["all"]:
|
||||
if not can_view_all_instances(user):
|
||||
raise HTTPException(status_code=403, detail="Backing up all guests requires vm:manage")
|
||||
return ["all"]
|
||||
try:
|
||||
cleaned = sorted({int(v) for v in vmids})
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="vmids must be integers or 'all'")
|
||||
if any(v < 100 for v in cleaned):
|
||||
raise HTTPException(status_code=400, detail="Invalid VMID")
|
||||
for v in cleaned:
|
||||
require_vm_access(db, user, server_id, v)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _check_cron(expr) -> str:
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
if not isinstance(expr, str) or len(expr.split()) != 5:
|
||||
raise HTTPException(status_code=400, detail="cron_expression must have 5 fields")
|
||||
try:
|
||||
CronTrigger.from_crontab(expr, timezone="UTC")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid cron expression: {e}")
|
||||
return expr.strip()
|
||||
|
||||
|
||||
def _int_field(data: dict, name: str, default: int) -> int:
|
||||
try:
|
||||
value = int(data.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail=f"{name} must be an integer")
|
||||
if value < 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} must not be negative")
|
||||
return value
|
||||
|
||||
|
||||
@router.get("/api/backups/jobs")
|
||||
def list_backup_jobs(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -767,18 +940,18 @@ async def create_backup_job(
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
job = BackupJob(
|
||||
server_id=data["server_id"],
|
||||
server_id=server.id,
|
||||
node=data["node"],
|
||||
vmids=data["vmids"],
|
||||
vmids=_clean_job_vmids(db, current_user, server.id, data["vmids"]),
|
||||
storage=data["storage"],
|
||||
mode=data.get("mode", "snapshot"),
|
||||
compress=data.get("compress", "zstd"),
|
||||
notes=data.get("notes"),
|
||||
keep_last=int(data.get("keep_last", 3)),
|
||||
keep_daily=int(data.get("keep_daily", 7)),
|
||||
keep_weekly=int(data.get("keep_weekly", 4)),
|
||||
keep_monthly=int(data.get("keep_monthly", 6)),
|
||||
cron_expression=data["cron_expression"],
|
||||
keep_last=_int_field(data, "keep_last", 3),
|
||||
keep_daily=_int_field(data, "keep_daily", 7),
|
||||
keep_weekly=_int_field(data, "keep_weekly", 4),
|
||||
keep_monthly=_int_field(data, "keep_monthly", 6),
|
||||
cron_expression=_check_cron(data["cron_expression"]),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
owner_id=current_user.id,
|
||||
)
|
||||
@@ -809,16 +982,18 @@ async def update_backup_job(
|
||||
current_user: User = Depends(PermissionChecker("backup:manage")),
|
||||
):
|
||||
"""Update a scheduled backup job"""
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
if not (current_user.is_admin or
|
||||
(current_user.role and current_user.role.name == "admin") or
|
||||
job.owner_id == current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
job = _get_job_for_user(db, current_user, job_id)
|
||||
|
||||
data = await request.json()
|
||||
if "vmids" in data:
|
||||
data["vmids"] = _clean_job_vmids(db, current_user, job.server_id, data["vmids"])
|
||||
if "cron_expression" in data:
|
||||
data["cron_expression"] = _check_cron(data["cron_expression"])
|
||||
for name in ("keep_last", "keep_daily", "keep_weekly", "keep_monthly"):
|
||||
if name in data:
|
||||
data[name] = _int_field(data, name, 0)
|
||||
if "enabled" in data:
|
||||
data["enabled"] = bool(data["enabled"])
|
||||
updatable = ["node", "vmids", "storage", "mode", "compress", "notes",
|
||||
"keep_last", "keep_daily", "keep_weekly", "keep_monthly",
|
||||
"cron_expression", "enabled"]
|
||||
@@ -845,14 +1020,7 @@ def delete_backup_job(
|
||||
current_user: User = Depends(PermissionChecker("backup:manage")),
|
||||
):
|
||||
"""Delete a scheduled backup job"""
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
if not (current_user.is_admin or
|
||||
(current_user.role and current_user.role.name == "admin") or
|
||||
job.owner_id == current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
job = _get_job_for_user(db, current_user, job_id)
|
||||
|
||||
try:
|
||||
from ...services.backup_scheduler import scheduler
|
||||
@@ -872,9 +1040,7 @@ def toggle_backup_job(
|
||||
current_user: User = Depends(PermissionChecker("backup:manage")),
|
||||
):
|
||||
"""Enable or disable a scheduled backup job"""
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _get_job_for_user(db, current_user, job_id)
|
||||
|
||||
job.enabled = not job.enabled
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
@@ -899,9 +1065,7 @@ def run_backup_job_now(
|
||||
current_user: User = Depends(PermissionChecker("backup:create")),
|
||||
):
|
||||
"""Trigger a scheduled backup job immediately"""
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
_get_job_for_user(db, current_user, job_id)
|
||||
|
||||
try:
|
||||
from ...services.backup_scheduler import run_backup_job
|
||||
|
||||
@@ -19,7 +19,11 @@ from ...proxmox import ProxmoxClient
|
||||
from ._helpers import _get_proxmox_client, _extract_fingerprint, get_visible_server_ids
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
|
||||
@@ -6,10 +6,14 @@ from loguru import logger
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ._helpers import _get_proxmox_client, require_vm_access
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
@@ -595,6 +599,7 @@ async def get_guest_firewall_options(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:view")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
@@ -615,6 +620,7 @@ async def update_guest_firewall_options(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:manage")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
@@ -637,6 +643,7 @@ async def get_guest_firewall_rules(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:view")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
@@ -657,6 +664,7 @@ async def create_guest_firewall_rule(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:manage")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
@@ -681,6 +689,7 @@ async def update_guest_firewall_rule(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:manage")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
@@ -704,6 +713,7 @@ async def delete_guest_firewall_rule(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:manage")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
@@ -727,6 +737,7 @@ async def get_guest_firewall_log(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("firewall:view")),
|
||||
):
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
|
||||
@@ -7,8 +7,10 @@ qcow2 (content=import) / vztmpl с прогрессом через DeployTask +
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import shutil
|
||||
import tempfile
|
||||
from email.message import Message as _EmailMessage
|
||||
@@ -26,9 +28,13 @@ from ...db import get_db
|
||||
from ...models import ProxmoxServer, User, DeployTask, ImageMirror
|
||||
from ...auth import PermissionChecker
|
||||
from ...catalog import CATALOG, get_catalog_image
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ._helpers import _get_proxmox_client, PveNode, require_node_name, require_pve_id
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _sanitize_filename(name: str, default: str = 'upload.iso') -> str:
|
||||
@@ -52,7 +58,7 @@ class ImageDownloadResponse(BaseModel):
|
||||
|
||||
|
||||
class ImageDownloadRequest(BaseModel):
|
||||
node: str
|
||||
node: PveNode
|
||||
storage: str
|
||||
# Либо ссылка на запись каталога/зеркала...
|
||||
source_id: Optional[str] = None
|
||||
@@ -275,10 +281,33 @@ def _filename_from_url_response(url: str, headers) -> str:
|
||||
return unquote(path.rsplit('/', 1)[-1]) or 'download'
|
||||
|
||||
|
||||
async def _reject_internal_target(request: httpx.Request) -> None:
|
||||
"""httpx request hook: refuse loopback / link-local / unspecified targets.
|
||||
|
||||
The panel fetches the URL itself, so without this a caller could probe the
|
||||
panel's own services (DB, API on localhost) or cloud metadata endpoints
|
||||
(169.254.169.254) — on every redirect hop, not just the first URL.
|
||||
Private LAN ranges stay allowed: internal mirrors are a legitimate source.
|
||||
"""
|
||||
if request.url.scheme not in ("http", "https"):
|
||||
raise httpx.UnsupportedProtocol(f"Scheme not allowed: {request.url.scheme}")
|
||||
host = request.url.host
|
||||
try:
|
||||
infos = await asyncio.get_running_loop().getaddrinfo(host, None)
|
||||
except socket.gaierror as e:
|
||||
raise httpx.ConnectError(f"Cannot resolve {host}: {e}")
|
||||
for info in infos:
|
||||
addr = ipaddress.ip_address(info[4][0].split("%")[0])
|
||||
if addr.is_loopback or addr.is_link_local or addr.is_unspecified or addr.is_multicast:
|
||||
raise httpx.ConnectError(f"Destination not allowed: {host}")
|
||||
|
||||
|
||||
@router.get("/api/images/resolve-filename")
|
||||
async def resolve_image_filename(
|
||||
url: str = Query(..., description="URL образа для определения реального имени файла"),
|
||||
current_user: User = Depends(PermissionChecker("template:view")),
|
||||
# Only the download dialog (template:manage) needs this; it makes the panel
|
||||
# issue requests to caller-chosen URLs, so it is not for read-only roles.
|
||||
current_user: User = Depends(PermissionChecker("template:manage")),
|
||||
):
|
||||
"""
|
||||
Определить настоящее имя файла по произвольному URL.
|
||||
@@ -289,8 +318,13 @@ async def resolve_image_filename(
|
||||
а если сервер не поддерживает HEAD — открываем GET-поток и сразу же его
|
||||
закрываем, не скачивая тело.
|
||||
"""
|
||||
if urlparse(url).scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Поддерживаются только http(s)-ссылки")
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0, verify=False) as hc:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, max_redirects=5, timeout=10.0, verify=False,
|
||||
event_hooks={"request": [_reject_internal_target]},
|
||||
) as hc:
|
||||
try:
|
||||
resp = await hc.head(url)
|
||||
if resp.status_code >= 400:
|
||||
@@ -439,6 +473,9 @@ async def upload_image_file(
|
||||
|
||||
if content not in ('iso', 'vztmpl'):
|
||||
raise HTTPException(status_code=400, detail=f"Неподдерживаемый тип контента: {content}")
|
||||
# Form fields are not covered by the router's query/path validation.
|
||||
require_node_name(node)
|
||||
require_pve_id(storage, "storage")
|
||||
|
||||
final_name = _sanitize_filename(filename or file.filename or 'upload.iso')
|
||||
if content == 'iso':
|
||||
@@ -446,15 +483,18 @@ async def upload_image_file(
|
||||
|
||||
tmp_dir = tempfile.mkdtemp(prefix='pveup_')
|
||||
tmp_path = os.path.join(tmp_dir, final_name)
|
||||
def _copy_upload() -> int:
|
||||
# Starlette has already spooled the body to a temp file; copy it in one
|
||||
# worker-thread call (two thread hops per 4 MB chunk for a multi-GB ISO
|
||||
# before), keeping the event loop free.
|
||||
file.file.seek(0)
|
||||
with open(tmp_path, 'wb') as out:
|
||||
shutil.copyfileobj(file.file, out, 16 * 1024 * 1024)
|
||||
return out.tell()
|
||||
|
||||
size = 0
|
||||
try:
|
||||
with open(tmp_path, 'wb') as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
size += len(chunk)
|
||||
size = await asyncio.to_thread(_copy_upload)
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail=f"Не удалось сохранить файл: {e}")
|
||||
|
||||
@@ -25,9 +25,14 @@ from ...ipam_service import IPAMService
|
||||
from ...logging_service import LoggingService
|
||||
from ...models import DeployTask, IPAMNetwork, ProxmoxServer, User
|
||||
from ...websocket_manager import broadcast_task_update
|
||||
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance
|
||||
from ._helpers import (_get_proxmox_client, get_next_vmid, save_vm_instance,
|
||||
require_server_visible, ISO_VOLID_RE, PveNode)
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
_iso_vm_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="iso_vm")
|
||||
|
||||
@@ -44,7 +49,7 @@ def _utcnow() -> datetime:
|
||||
|
||||
class ISOVMCreateRequest(BaseModel):
|
||||
server_id: int
|
||||
node: str
|
||||
node: PveNode
|
||||
name: str
|
||||
iso: str # volid, e.g. "local:iso/ubuntu-24.04.iso"
|
||||
vmid: Optional[int] = None
|
||||
@@ -323,8 +328,14 @@ async def create_vm_from_iso(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not req.node:
|
||||
raise HTTPException(status_code=400, detail="Укажите ноду для размещения ВМ")
|
||||
require_server_visible(db, current_user, server.id)
|
||||
if not req.iso:
|
||||
raise HTTPException(status_code=400, detail="Выберите установочный ISO-образ")
|
||||
# Only ISO volumes: any other volid (another guest's disk) would be
|
||||
# attached to the new VM as a CD-ROM.
|
||||
for volid in (req.iso, req.extra_iso):
|
||||
if volid and not ISO_VOLID_RE.match(volid):
|
||||
raise HTTPException(status_code=400, detail="ISO must be <storage>:iso/<file>")
|
||||
if req.disk_bus not in DISK_BUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Шина диска должна быть одной из: {', '.join(DISK_BUSES)}")
|
||||
if req.net_model not in NET_MODELS:
|
||||
|
||||
@@ -22,9 +22,15 @@ from ...logging_service import LoggingService
|
||||
from ...models import DeployTask, IPAMNetwork, ProxmoxServer, User, VMInstance
|
||||
from ...proxmox import ProxmoxClient
|
||||
from ...websocket_manager import broadcast_task_update
|
||||
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance
|
||||
from ._helpers import (_get_proxmox_client, get_next_vmid, save_vm_instance,
|
||||
can_view_all_instances, require_server_visible, VZTMPL_VOLID_RE,
|
||||
PveNode)
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
_lxc_deploy_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="lxc_deploy")
|
||||
|
||||
@@ -38,7 +44,7 @@ def _utcnow() -> datetime:
|
||||
class LXCDeployRequest(BaseModel):
|
||||
server_id: int
|
||||
ostemplate: str # volid, e.g. "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
|
||||
node: Optional[str] = None
|
||||
node: Optional[PveNode] = None
|
||||
name: str # hostname / display name
|
||||
vmid: Optional[int] = None
|
||||
cores: int = 1
|
||||
@@ -454,9 +460,17 @@ async def deploy_lxc_from_template(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
require_server_visible(db, current_user, server.id)
|
||||
|
||||
if not req.ostemplate:
|
||||
raise HTTPException(status_code=400, detail="Необходимо указать ostemplate")
|
||||
# A vzdump archive is accepted by `pct create` too (that is a restore):
|
||||
# it would hand over another guest's filesystem.
|
||||
if not VZTMPL_VOLID_RE.match(req.ostemplate):
|
||||
raise HTTPException(status_code=400, detail="ostemplate must be a CT template (<storage>:vztmpl/<file>)")
|
||||
# root in a privileged container is root on the node.
|
||||
if not req.unprivileged and not can_view_all_instances(current_user):
|
||||
raise HTTPException(status_code=403, detail="Privileged containers require the vm:manage permission")
|
||||
if not req.name or len(req.name) < 1:
|
||||
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
|
||||
from ...schemas import validate_instance_name
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# ==================== Node list ====================
|
||||
@@ -29,6 +33,8 @@ def get_server_nodes(
|
||||
client = _get_proxmox_client(server)
|
||||
nodes = client.get_nodes()
|
||||
return JSONResponse(content={"nodes": nodes})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting nodes for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -99,6 +105,8 @@ def list_node_networks(
|
||||
sdn_vnets.append(record)
|
||||
|
||||
return JSONResponse(content={"node": node, "interfaces": enriched, "sdn_vnets": sdn_vnets})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing node {node} networks for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
|
||||
@@ -11,7 +11,11 @@ from ...logging_service import LoggingService
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# ==================== SDN (Software Defined Networking) ====================
|
||||
@@ -64,6 +68,8 @@ def get_sdn_zones(
|
||||
|
||||
zones = client.get_sdn_zones()
|
||||
return JSONResponse(content={"zones": zones})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN zones for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -83,6 +89,8 @@ def get_sdn_dns(
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
return JSONResponse(content={"dns": client.get_sdn_dns()})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN DNS entries for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -171,6 +179,8 @@ def get_sdn_vnets(
|
||||
|
||||
vnets = client.get_sdn_vnets()
|
||||
return JSONResponse(content={"vnets": vnets})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN vnets for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -261,6 +271,8 @@ def get_sdn_subnets(
|
||||
|
||||
subnets = client.get_sdn_subnets(vnet)
|
||||
return JSONResponse(content={"subnets": subnets})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN subnets for vnet {vnet}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -27,7 +27,11 @@ from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
can_view_all_instances, get_owned_vmids, get_visible_server_ids)
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
def _attach_workspaces(db: Session, servers: list) -> None:
|
||||
@@ -457,6 +461,59 @@ def test_proxmox_credentials(
|
||||
return {"success": False, "status": "error", "message": str(e)}
|
||||
|
||||
|
||||
def _ssh_hosts(server: ProxmoxServer) -> list:
|
||||
"""Host names the panel uses for SSH to this server (port suffix stripped)."""
|
||||
hosts = []
|
||||
for value in (server.ip_address, server.hostname):
|
||||
host = (value or "").split(":")[0].strip()
|
||||
if host and host not in hosts:
|
||||
hosts.append(host)
|
||||
return hosts
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/ssh-host-key")
|
||||
def get_ssh_host_key(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:update"))
|
||||
):
|
||||
"""Pinned (trust-on-first-use) SSH host keys of the server."""
|
||||
from ...ssh_client import pinned_host_keys
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
return {"hosts": [{"host": h, "keys": pinned_host_keys(h)} for h in _ssh_hosts(server)]}
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/ssh-host-key")
|
||||
def reset_ssh_host_key(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:update"))
|
||||
):
|
||||
"""Forget the pinned SSH host key — e.g. after the node was reinstalled.
|
||||
|
||||
The next SSH connection pins whatever key the host presents then, so only
|
||||
do this when the key change is expected.
|
||||
"""
|
||||
from ...ssh_client import forget_host_key
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
removed = sum(forget_host_key(h) for h in _ssh_hosts(server))
|
||||
logger.warning(
|
||||
f"User {current_user.username} reset pinned SSH host key of server {server.name} "
|
||||
f"({removed} entr{'y' if removed == 1 else 'ies'} removed)"
|
||||
)
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db, action="ssh_host_key_reset", resource_type="server",
|
||||
resource_id=server.id, username=current_user.username,
|
||||
server_id=server.id, server_name=server.name, success=True,
|
||||
details={"removed": removed},
|
||||
)
|
||||
return {"success": True, "removed": removed}
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/provision-token", status_code=status.HTTP_200_OK)
|
||||
def provision_server_token(
|
||||
server_id: int,
|
||||
@@ -628,6 +685,8 @@ def get_server_resources(
|
||||
"containers": containers,
|
||||
"node_uptime": node_uptime
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
server.update_status(False, str(e))
|
||||
db.commit()
|
||||
@@ -786,6 +845,8 @@ def get_storages(
|
||||
storages.sort(key=lambda x: (not x.get('active', 1), x.get('storage', '')))
|
||||
|
||||
return JSONResponse(content={"storages": storages})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting storages: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -814,6 +875,8 @@ def get_node_status(
|
||||
raise HTTPException(status_code=404, detail="Node status not found")
|
||||
|
||||
return JSONResponse(content=status)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -840,6 +903,8 @@ def get_node_rrddata(
|
||||
|
||||
rrddata = client.get_node_rrddata(node, timeframe)
|
||||
return JSONResponse(content=rrddata)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} RRD data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -20,7 +20,11 @@ from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# ==================== Snapshots ====================
|
||||
@@ -48,6 +52,8 @@ def get_vm_snapshots(
|
||||
# Filter out 'current' pseudo-snapshot if present
|
||||
snapshots = [s for s in snapshots if s.get('name') != 'current']
|
||||
return JSONResponse(content={"snapshots": snapshots})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} snapshots: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -225,6 +231,8 @@ def get_container_snapshots(
|
||||
# Filter out 'current' pseudo-snapshot if present
|
||||
snapshots = [s for s in snapshots if s.get('name') != 'current']
|
||||
return JSONResponse(content={"snapshots": snapshots})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} snapshots: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List
|
||||
@@ -22,7 +22,11 @@ from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
|
||||
# ==================== ProxmoxTaskService ====================
|
||||
@@ -79,6 +83,26 @@ class ProxmoxTaskService:
|
||||
)
|
||||
|
||||
|
||||
def _require_upid_access(db: Session, user: User, server_id: int, upid: str) -> None:
|
||||
"""Tenants may only read tasks they started or that target their own guests.
|
||||
|
||||
Task logs carry node paths, storage names and other tenants' guest details;
|
||||
vm:view (granted to every standard user) must not open all of them.
|
||||
"""
|
||||
from ._helpers import can_view_all_instances, get_owned_vmids
|
||||
if can_view_all_instances(user):
|
||||
return
|
||||
if db.query(ProxmoxTask.id).filter(
|
||||
ProxmoxTask.upid == upid, ProxmoxTask.user_id == user.id
|
||||
).first():
|
||||
return
|
||||
# UPID:<node>:<pid>:<pstart>:<starttime>:<type>:<id>:<user>:
|
||||
parts = upid.split(":")
|
||||
if len(parts) >= 7 and parts[6].isdigit() and int(parts[6]) in get_owned_vmids(db, user, server_id):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="You do not have access to this task")
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/task/{upid}/status")
|
||||
def get_task_status(
|
||||
server_id: int,
|
||||
@@ -91,6 +115,7 @@ def get_task_status(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
_require_upid_access(db, current_user, server_id, upid)
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
@@ -121,6 +146,7 @@ def get_task_log(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
_require_upid_access(db, current_user, server_id, upid)
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
@@ -209,11 +235,14 @@ def get_all_tasks(
|
||||
Return both TaskQueue (bulk ops) and ProxmoxTask (UPID) records for the
|
||||
current user, merged and sorted by created_at descending.
|
||||
"""
|
||||
# Large text columns are not part of the list payload — don't load them.
|
||||
bulk_q = db.query(TaskQueue).filter(TaskQueue.user_id == current_user.id)
|
||||
proxmox_q = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == current_user.id)
|
||||
proxmox_q = (db.query(ProxmoxTask).options(defer(ProxmoxTask.log_text))
|
||||
.filter(ProxmoxTask.user_id == current_user.id))
|
||||
deploy_q = db.query(DeployTask).filter(DeployTask.user_id == current_user.id)
|
||||
appstore_q = db.query(AppOperation).filter(AppOperation.user_id == current_user.id)
|
||||
script_q = db.query(ScriptExecution).filter(ScriptExecution.user_id == current_user.id)
|
||||
script_q = (db.query(ScriptExecution).options(defer(ScriptExecution.output))
|
||||
.filter(ScriptExecution.user_id == current_user.id))
|
||||
|
||||
if status_filter:
|
||||
# Normalise statuses: proxmox uses running/completed/failed; bulk uses the same + pending/cancelled
|
||||
|
||||
@@ -30,7 +30,11 @@ from ...schemas import (NetworkTopologyResponse, TopologyBridge, TopologyCluster
|
||||
TopologyPanelSummary, TopologyWarning)
|
||||
from ._helpers import _get_proxmox_client, can_view_all_instances, get_visible_server_ids
|
||||
|
||||
router = APIRouter()
|
||||
from ._helpers import enforce_server_visibility # noqa: E402
|
||||
|
||||
# Every {server_id} route: tenants only reach servers assigned to them
|
||||
# (directly or through a workspace) — see enforce_server_visibility.
|
||||
router = APIRouter(dependencies=[Depends(enforce_server_visibility)])
|
||||
|
||||
# Live cluster/resources + node networks, keyed by server id. Short TTL: the
|
||||
# page auto-refreshes every 30s and several users may watch it at once.
|
||||
|
||||
@@ -15,14 +15,17 @@ from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwo
|
||||
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
|
||||
from ...proxmox import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import (get_current_user, PermissionChecker, require_permission,
|
||||
check_permission, authenticate_ws_token)
|
||||
check_permission, authenticate_ws_session, watch_ws_access)
|
||||
from ...rbac import PermissionEngine
|
||||
from ...logging_service import LoggingService
|
||||
from ...ipam_service import IPAMService
|
||||
from ._helpers import enforce_server_visibility, pve_authority, stash_pve_ticket, claim_pve_ticket
|
||||
from ._helpers import PveNode, require_node_name
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
can_view_all_instances, pretty_os_from_template)
|
||||
can_view_all_instances, pretty_os_from_template,
|
||||
get_visible_server_ids, _NO_WORKSPACE_HEADER)
|
||||
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
|
||||
|
||||
router = APIRouter()
|
||||
@@ -77,22 +80,34 @@ from ...models import TaskQueue
|
||||
from ...services.task_queue_service import TaskQueueService, process_task_queue
|
||||
from ...api.proxmox.tasks import ProxmoxTaskService
|
||||
from pydantic import BaseModel
|
||||
from typing import List as TypingList, Optional
|
||||
from typing import List as TypingList, Literal, Optional
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class BulkOperationItem(BaseModel):
|
||||
server_id: int
|
||||
vmid: int
|
||||
vm_type: str # 'qemu' or 'lxc'
|
||||
name: str
|
||||
node: str
|
||||
target_node: Optional[str] = None # для action=migrate
|
||||
vm_type: Literal['qemu', 'lxc']
|
||||
name: str = Field(..., max_length=255)
|
||||
node: PveNode
|
||||
target_node: Optional[PveNode] = None # для action=migrate
|
||||
online: Optional[bool] = None # live-миграция запущенной VM
|
||||
|
||||
|
||||
class BulkOperationRequest(BaseModel):
|
||||
action: str # start, stop, restart, shutdown, delete, migrate
|
||||
items: TypingList[BulkOperationItem]
|
||||
items: TypingList[BulkOperationItem] = Field(..., max_length=500)
|
||||
|
||||
|
||||
# Same permissions as the single-guest endpoints (control_vm / migrate / delete).
|
||||
_BULK_ACTION_PERMISSIONS = {
|
||||
'start': {'qemu': 'vm:start', 'lxc': 'vm:start'},
|
||||
'stop': {'qemu': 'vm:stop', 'lxc': 'vm:stop'},
|
||||
'shutdown': {'qemu': 'vm:stop', 'lxc': 'vm:stop'},
|
||||
'restart': {'qemu': 'vm:restart', 'lxc': 'vm:restart'},
|
||||
'delete': {'qemu': 'vm:delete', 'lxc': 'vm:delete'},
|
||||
'migrate': {'qemu': 'vm:migrate', 'lxc': 'lxc:migrate'},
|
||||
}
|
||||
|
||||
|
||||
class VMOwnerUpdate(BaseModel):
|
||||
@@ -387,6 +402,7 @@ def execute_vm_command(
|
||||
- free -m
|
||||
- systemctl status nginx
|
||||
"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -399,6 +415,8 @@ def execute_vm_command(
|
||||
|
||||
result = client.execute_command(node, vmid, command, timeout)
|
||||
return JSONResponse(content=result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing command on VM {vmid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -421,6 +439,7 @@ def execute_vm_script(
|
||||
Скрипт будет сохранен во временный файл на VM и выполнен.
|
||||
После выполнения временный файл будет удален.
|
||||
"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -433,6 +452,8 @@ def execute_vm_script(
|
||||
|
||||
result = client.execute_script(node, vmid, script, interpreter, timeout)
|
||||
return JSONResponse(content=result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing script on VM {vmid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -443,7 +464,7 @@ def execute_vm_script(
|
||||
class CloneRequest(BaseModel):
|
||||
new_name: str
|
||||
full: bool = True
|
||||
target_node: Optional[str] = None
|
||||
target_node: Optional[PveNode] = None
|
||||
target_storage: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
owner_id: Optional[int] = None
|
||||
@@ -462,15 +483,31 @@ def _resolve_clone_owner(db: Session, current_user: User, owner_id: Optional[int
|
||||
return current_user.id
|
||||
|
||||
|
||||
def _check_clone_quota(db: Session, server_id: int, vmid: int, owner_id: int) -> None:
|
||||
"""A clone is a new instance with the source's resources — count it.
|
||||
|
||||
Deploy, LXC and ISO creation enforced quotas, cloning did not, so a tenant
|
||||
could multiply one small allowed VM past every limit.
|
||||
"""
|
||||
from ...services.quota_service import check_quota
|
||||
src = get_vm_instance(db, server_id, vmid)
|
||||
check_quota(
|
||||
db, owner_id,
|
||||
add_cores=(src.cores or 0) if src else 0,
|
||||
add_memory_mb=(src.memory or 0) if src else 0,
|
||||
add_disk_gb=(src.disk_size or 0) if src else 0,
|
||||
)
|
||||
|
||||
|
||||
class MigrateRequest(BaseModel):
|
||||
target_node: str
|
||||
target_node: PveNode
|
||||
target_storage: Optional[str] = None
|
||||
online: bool = False
|
||||
|
||||
|
||||
class RemoteMigrateRequest(BaseModel):
|
||||
target_server_id: int
|
||||
target_node: str
|
||||
target_node: PveNode
|
||||
target_vmid: Optional[int] = None
|
||||
target_storage: Optional[str] = None
|
||||
target_bridge: Optional[str] = None
|
||||
@@ -479,8 +516,10 @@ class RemoteMigrateRequest(BaseModel):
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
username: str = "root"
|
||||
password: str
|
||||
# Fed to chpasswd as "user:password" lines: a newline would add a second
|
||||
# entry, a colon in the name would shift the password field.
|
||||
username: str = Field(default="root", pattern=r"^[^:\s]{1,64}$")
|
||||
password: str = Field(..., min_length=1, max_length=256, pattern=r"^[^\r\n]+$")
|
||||
|
||||
|
||||
def _resolve_server(db: Session, server_id: int) -> ProxmoxServer:
|
||||
@@ -516,6 +555,7 @@ def clone_vm_endpoint(
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
_resolve_server(db, server_id)
|
||||
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||
_check_clone_quota(db, server_id, vmid, owner_id)
|
||||
|
||||
task = DeployTask(
|
||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||
@@ -548,6 +588,7 @@ def clone_container_endpoint(
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
_resolve_server(db, server_id)
|
||||
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||
_check_clone_quota(db, server_id, vmid, owner_id)
|
||||
|
||||
task = DeployTask(
|
||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||
@@ -1102,7 +1143,7 @@ async def delete_vm(
|
||||
|
||||
# ==================== High Availability (HA) Endpoints ====================
|
||||
|
||||
@router.get("/api/{server_id}/ha/status")
|
||||
@router.get("/api/{server_id}/ha/status", dependencies=[Depends(enforce_server_visibility)])
|
||||
def get_ha_status(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1146,6 +1187,7 @@ def get_resource_ha_status(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить HA статус конкретной VM или контейнера"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
if vm_type not in ['vm', 'ct']:
|
||||
raise HTTPException(status_code=400, detail="vm_type must be 'vm' or 'ct'")
|
||||
|
||||
@@ -1200,6 +1242,7 @@ def add_resource_to_ha(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Добавить VM или контейнер в HA"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
if vm_type not in ['vm', 'ct']:
|
||||
raise HTTPException(status_code=400, detail="vm_type must be 'vm' or 'ct'")
|
||||
|
||||
@@ -1263,6 +1306,7 @@ def remove_resource_from_ha(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Удалить VM или контейнер из HA"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
if vm_type not in ['vm', 'ct']:
|
||||
raise HTTPException(status_code=400, detail="vm_type must be 'vm' or 'ct'")
|
||||
|
||||
@@ -1453,6 +1497,7 @@ def get_vm_config(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить конфигурацию VM"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1465,12 +1510,14 @@ def get_vm_config(
|
||||
|
||||
config = client.get_vm_config(node, vmid)
|
||||
return JSONResponse(content=config)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/pci")
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/pci", dependencies=[Depends(enforce_server_visibility)])
|
||||
def list_node_pci_devices(
|
||||
server_id: int,
|
||||
node: str,
|
||||
@@ -1487,7 +1534,7 @@ def list_node_pci_devices(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/usb")
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/usb", dependencies=[Depends(enforce_server_visibility)])
|
||||
def list_node_usb_devices(
|
||||
server_id: int,
|
||||
node: str,
|
||||
@@ -1514,6 +1561,7 @@ async def update_vm_config(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Обновить конфигурацию VM (CPU, Memory, etc.)"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1549,6 +1597,7 @@ async def resize_vm_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Изменить размер диска VM"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1625,6 +1674,7 @@ async def move_vm_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Переместить диск VM в другое хранилище (move_disk)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1666,6 +1716,7 @@ async def add_vm_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Добавить новый диск VM."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1709,6 +1760,7 @@ async def attach_vm_physical_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Пробросить физический диск ноды в VM (raw device passthrough)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1752,6 +1804,7 @@ async def detach_vm_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Отключить (или удалить) диск VM."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1787,6 +1840,7 @@ def unlock_vm_endpoint(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Снять блокировку (lock) с VM — аналог qm unlock."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1815,6 +1869,7 @@ def unlock_container_endpoint(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Снять блокировку (lock) с LXC — аналог pct unlock."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1844,6 +1899,7 @@ async def update_vm_cloud_init(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Обновить cloud-init параметры VM (user/password/SSH/IP/DNS)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1887,6 +1943,7 @@ def enable_vm_serial(
|
||||
Возвращает added=True, если устройство было только что добавлено (тогда для
|
||||
появления в гостевой ОС может потребоваться перезагрузка VM).
|
||||
"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1920,6 +1977,7 @@ async def resize_container_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Изменить размер диска контейнера"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -1978,6 +2036,7 @@ async def move_container_disk(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Переместить том контейнера в другое хранилище (move_volume)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2017,6 +2076,7 @@ def get_container_config(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить конфигурацию LXC контейнера"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2029,6 +2089,8 @@ def get_container_config(
|
||||
|
||||
config = client.get_container_config(node, vmid)
|
||||
return JSONResponse(content=config)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2044,6 +2106,7 @@ async def update_container_config(
|
||||
current_user: User = Depends(PermissionChecker("server:manage"))
|
||||
):
|
||||
"""Обновить конфигурацию LXC контейнера"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2069,7 +2132,7 @@ async def update_container_config(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/nodes")
|
||||
@router.get("/api/{server_id}/nodes", dependencies=[Depends(enforce_server_visibility)])
|
||||
def get_server_nodes(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -2114,6 +2177,8 @@ def get_server_nodes(
|
||||
|
||||
logger.info(f"Nodes for server {server_id} ({server.name}), cluster_id={cluster_id}: {len(nodes)} node(s)")
|
||||
return JSONResponse(content={"nodes": nodes, "cluster_id": cluster_id, "version": version})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting nodes from {server.name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2143,6 +2208,8 @@ def get_vm_status(
|
||||
|
||||
status = client.get_vm_status(node, vmid)
|
||||
return JSONResponse(content=status)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2170,6 +2237,8 @@ def get_container_status(
|
||||
|
||||
status = client.get_container_status(node, vmid)
|
||||
return JSONResponse(content=status)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2184,6 +2253,7 @@ def get_vm_interfaces(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить сетевые интерфейсы и IP адреса VM через QEMU guest agent"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2196,6 +2266,8 @@ def get_vm_interfaces(
|
||||
|
||||
interfaces = client.get_vm_interfaces(node, vmid)
|
||||
return JSONResponse(content={"interfaces": interfaces})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} interfaces: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2210,6 +2282,7 @@ def get_container_interfaces(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить сетевые интерфейсы и IP адреса контейнера"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2222,6 +2295,8 @@ def get_container_interfaces(
|
||||
|
||||
interfaces = client.get_container_interfaces(node, vmid)
|
||||
return JSONResponse(content={"interfaces": interfaces})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} interfaces: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2248,6 +2323,8 @@ def get_container_status_api(
|
||||
|
||||
status = client.get_container_status(node, vmid)
|
||||
return JSONResponse(content=status)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2263,6 +2340,7 @@ def get_vm_rrddata(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить исторические данные VM для графиков (CPU, RAM, Network, Disk IO)"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2275,6 +2353,8 @@ def get_vm_rrddata(
|
||||
|
||||
rrddata = client.get_vm_rrddata(node, vmid, timeframe)
|
||||
return JSONResponse(content=rrddata)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} RRD data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2290,6 +2370,7 @@ def get_container_rrddata(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Получить исторические данные контейнера для графиков"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2302,6 +2383,8 @@ def get_container_rrddata(
|
||||
|
||||
rrddata = client.get_container_rrddata(node, vmid, timeframe)
|
||||
return JSONResponse(content=rrddata)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} RRD data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2335,6 +2418,7 @@ def get_vm_metrics(
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
"""Retrieve VM metrics time-series from the local DB (no Proxmox call)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
f, t = _resolve_metrics_window(timeframe, from_ts, to_ts)
|
||||
return query_instance_metrics(db, server_id, vmid, f, t, nic)
|
||||
|
||||
@@ -2352,6 +2436,7 @@ def get_container_metrics(
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
"""Retrieve container metrics time-series from the local DB (no Proxmox call)."""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
f, t = _resolve_metrics_window(timeframe, from_ts, to_ts)
|
||||
return query_instance_metrics(db, server_id, vmid, f, t, nic)
|
||||
|
||||
@@ -2387,7 +2472,7 @@ def get_vm_vnc(
|
||||
|
||||
# 1. Получаем auth ticket
|
||||
auth_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={
|
||||
"username": auth_username,
|
||||
"password": password_to_use
|
||||
@@ -2407,7 +2492,7 @@ def get_vm_vnc(
|
||||
# generate-password=1: Proxmox 8.x возвращает случайный VNC пароль для VNCAuth;
|
||||
# в Proxmox 9.x (NoAuth) поле password пустое, но соединение проходит без пароля.
|
||||
vnc_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/qemu/{vmid}/vncproxy",
|
||||
data={"websocket": 1, "generate-password": 1},
|
||||
headers={
|
||||
"CSRFPreventionToken": csrf_token
|
||||
@@ -2429,11 +2514,11 @@ def get_vm_vnc(
|
||||
'port': vnc_data.get('port'),
|
||||
'ticket': vnc_data.get('ticket'),
|
||||
'password': vnc_password,
|
||||
'host': server.ip_address,
|
||||
'node': node,
|
||||
'vmid': vmid,
|
||||
'type': 'qemu',
|
||||
'auth_ticket': auth_ticket
|
||||
# Opaque handle, redeemed by the WS proxy — never the PVE ticket itself.
|
||||
'auth_ticket': stash_pve_ticket(auth_ticket, current_user.id, server_id, vmid),
|
||||
}
|
||||
|
||||
logger.info(f"User {current_user.username} opened VNC console for VM {vmid} (password_len={len(vnc_password)}, has_generate_password={'password' in vnc_data})")
|
||||
@@ -2473,7 +2558,7 @@ def get_container_vnc(
|
||||
|
||||
# 1. Получаем auth ticket
|
||||
auth_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={
|
||||
"username": auth_username,
|
||||
"password": password_to_use
|
||||
@@ -2492,7 +2577,7 @@ def get_container_vnc(
|
||||
# 2. Создаём VNC proxy с этим же ticket
|
||||
# LXC vncproxy не поддерживает generate-password; используем ticket как VNCAuth пароль
|
||||
vnc_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/vncproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/lxc/{vmid}/vncproxy",
|
||||
data={"websocket": 1},
|
||||
headers={
|
||||
"CSRFPreventionToken": csrf_token
|
||||
@@ -2513,11 +2598,11 @@ def get_container_vnc(
|
||||
'port': vnc_data.get('port'),
|
||||
'ticket': vnc_data.get('ticket'),
|
||||
'password': vnc_password,
|
||||
'host': server.ip_address,
|
||||
'node': node,
|
||||
'vmid': vmid,
|
||||
'type': 'lxc',
|
||||
'auth_ticket': auth_ticket
|
||||
# Opaque handle, redeemed by the WS proxy — never the PVE ticket itself.
|
||||
'auth_ticket': stash_pve_ticket(auth_ticket, current_user.id, server_id, vmid),
|
||||
}
|
||||
|
||||
logger.info(f"User {current_user.username} opened VNC console for container {vmid}")
|
||||
@@ -2542,7 +2627,7 @@ 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
|
||||
ticket: str = Query(None), # WS-тикет панели (POST /api/auth/ws-ticket)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""WebSocket прокси для VNC подключения к Proxmox"""
|
||||
@@ -2550,14 +2635,15 @@ async def vnc_websocket_proxy(
|
||||
import urllib.parse
|
||||
|
||||
# --- Authenticate the panel user before bridging to Proxmox ------------
|
||||
user = authenticate_ws_token(token, db)
|
||||
user, ws_session = authenticate_ws_session(ticket, db)
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
if not PermissionEngine.has_permission(user, "vm:console"):
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
return
|
||||
if not check_vm_access(db, user, server_id, vmid):
|
||||
|
||||
def _still_allowed(db_, u):
|
||||
return PermissionEngine.has_permission(u, "vm:console") and check_vm_access(db_, u, server_id, vmid)
|
||||
|
||||
if not _still_allowed(db, user):
|
||||
await websocket.close(code=4003, reason="Access denied")
|
||||
return
|
||||
|
||||
@@ -2570,15 +2656,19 @@ async def vnc_websocket_proxy(
|
||||
await websocket.close(code=1008, reason="Proxmox server not found")
|
||||
return
|
||||
|
||||
# Используем auth_ticket переданный с frontend (он создан в той же сессии что и vncticket)
|
||||
# auth_ticket is the handle from GET .../vnc; the PVE ticket behind it was
|
||||
# created in the same session as vncticket.
|
||||
if auth_ticket:
|
||||
logger.info(f"Using auth_ticket from frontend for VNC WebSocket")
|
||||
auth_ticket = claim_pve_ticket(auth_ticket, user.id, server_id, vmid)
|
||||
if not auth_ticket:
|
||||
await websocket.close(code=4003, reason="Console session expired, reopen the console")
|
||||
return
|
||||
|
||||
# Построить URL для Proxmox WebSocket
|
||||
# Преобразуем vmtype: vm -> qemu, container -> lxc
|
||||
proxmox_vmtype = "qemu" if vmtype == "vm" else "lxc"
|
||||
encoded_ticket = urllib.parse.quote(vncticket, safe='')
|
||||
proxmox_ws_url = f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/{proxmox_vmtype}/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}"
|
||||
proxmox_ws_url = f"wss://{pve_authority(server)}/api2/json/nodes/{node}/{proxmox_vmtype}/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}"
|
||||
|
||||
logger.info(f"Connecting to Proxmox VNC: {server.name} for {proxmox_vmtype}/{vmid}")
|
||||
|
||||
@@ -2667,7 +2757,9 @@ async def vnc_websocket_proxy(
|
||||
done, pending = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(client_to_proxmox()),
|
||||
asyncio.create_task(proxmox_to_client())
|
||||
asyncio.create_task(proxmox_to_client()),
|
||||
# Closes the bridge once the session/permission is gone.
|
||||
asyncio.create_task(watch_ws_access(user.id, ws_session, _still_allowed)),
|
||||
],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -2698,6 +2790,7 @@ def get_saved_vm_config(
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Get saved VM configuration from database"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
instance = get_vm_instance(db, server_id, vmid)
|
||||
if not instance:
|
||||
return JSONResponse(content={"found": False})
|
||||
@@ -2723,7 +2816,7 @@ def get_saved_vm_config(
|
||||
|
||||
# ==================== LXC Container Creation ====================
|
||||
|
||||
@router.get("/api/{server_id}/all-lxc-templates")
|
||||
@router.get("/api/{server_id}/all-lxc-templates", dependencies=[Depends(enforce_server_visibility)])
|
||||
def get_all_lxc_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -2751,7 +2844,7 @@ def get_all_lxc_templates(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/available-lxc-templates")
|
||||
@router.get("/api/{server_id}/available-lxc-templates", dependencies=[Depends(enforce_server_visibility)])
|
||||
def get_available_lxc_templates(
|
||||
server_id: int,
|
||||
node: str,
|
||||
@@ -2779,7 +2872,7 @@ def get_available_lxc_templates(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/{server_id}/download-lxc-template")
|
||||
@router.post("/api/{server_id}/download-lxc-template", dependencies=[Depends(enforce_server_visibility)])
|
||||
async def download_lxc_template(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
@@ -2795,6 +2888,7 @@ async def download_lxc_template(
|
||||
node = data.get('node')
|
||||
storage = data.get('storage')
|
||||
template = data.get('template')
|
||||
require_node_name(node)
|
||||
|
||||
if not all([node, storage, template]):
|
||||
raise HTTPException(status_code=400, detail="Missing required parameters: node, storage, template")
|
||||
@@ -2876,7 +2970,7 @@ def get_vm_terminal(
|
||||
|
||||
# 1. Получаем auth ticket
|
||||
auth_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={"username": auth_username, "password": password_to_use},
|
||||
verify=server.verify_ssl,
|
||||
timeout=10
|
||||
@@ -2891,7 +2985,7 @@ def get_vm_terminal(
|
||||
|
||||
# 2. Создаём terminal proxy
|
||||
term_response = requests.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
|
||||
headers={"CSRFPreventionToken": csrf_token},
|
||||
cookies={"PVEAuthCookie": auth_ticket},
|
||||
verify=server.verify_ssl,
|
||||
@@ -2906,11 +3000,11 @@ def get_vm_terminal(
|
||||
response_data = {
|
||||
'port': term_data.get('port'),
|
||||
'ticket': term_data.get('ticket'),
|
||||
'host': server.ip_address,
|
||||
'node': node,
|
||||
'vmid': vmid,
|
||||
'type': 'qemu',
|
||||
'auth_ticket': auth_ticket
|
||||
# Opaque handle, redeemed by the WS proxy — never the PVE ticket itself.
|
||||
'auth_ticket': stash_pve_ticket(auth_ticket, current_user.id, server_id, vmid),
|
||||
}
|
||||
|
||||
logger.info(f"User {current_user.username} opened terminal for VM {vmid}")
|
||||
@@ -2932,6 +3026,7 @@ async def exec_in_container(
|
||||
current_user: User = Depends(PermissionChecker("vm:console"))
|
||||
):
|
||||
"""Выполнить команду в LXC контейнере через pct exec"""
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
@@ -2971,7 +3066,7 @@ async def terminal_websocket_fixed(
|
||||
server_id: int,
|
||||
node: str,
|
||||
vmid: int,
|
||||
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
|
||||
ticket: str = Query(None), # WS-тикет панели (POST /api/auth/ws-ticket)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""WebSocket терминал для LXC контейнеров через Proxmox termproxy API"""
|
||||
@@ -2980,14 +3075,15 @@ async def terminal_websocket_fixed(
|
||||
from urllib.parse import quote
|
||||
|
||||
# --- Authenticate the panel user before opening a root shell -----------
|
||||
user = authenticate_ws_token(token, db)
|
||||
user, ws_session = authenticate_ws_session(ticket, db)
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
if not PermissionEngine.has_permission(user, "vm:console"):
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
return
|
||||
if not check_vm_access(db, user, server_id, vmid):
|
||||
|
||||
def _still_allowed(db_, u):
|
||||
return PermissionEngine.has_permission(u, "vm:console") and check_vm_access(db_, u, server_id, vmid)
|
||||
|
||||
if not _still_allowed(db, user):
|
||||
await websocket.close(code=4003, reason="Access denied")
|
||||
return
|
||||
|
||||
@@ -3005,14 +3101,14 @@ async def terminal_websocket_fixed(
|
||||
|
||||
try:
|
||||
# Шаг 1: Auth ticket
|
||||
async with httpx.AsyncClient(verify=False, timeout=10) as client:
|
||||
async with httpx.AsyncClient(verify=server.verify_ssl, timeout=10) as client:
|
||||
if not server.password:
|
||||
await websocket.close(code=1011, reason="Terminal requires password authentication")
|
||||
return
|
||||
|
||||
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
|
||||
auth_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={"username": auth_username, "password": server.password}
|
||||
)
|
||||
if auth_resp.status_code != 200:
|
||||
@@ -3027,7 +3123,7 @@ async def terminal_websocket_fixed(
|
||||
|
||||
# Шаг 2: Termproxy
|
||||
term_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
|
||||
headers={"CSRFPreventionToken": csrf_token},
|
||||
cookies={"PVEAuthCookie": ticket}
|
||||
)
|
||||
@@ -3043,7 +3139,7 @@ async def terminal_websocket_fixed(
|
||||
|
||||
# Шаг 3: WebSocket к Proxmox — идентично VNC handler
|
||||
proxmox_ws_url = (
|
||||
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/vncwebsocket"
|
||||
f"wss://{pve_authority(server)}/api2/json/nodes/{node}/lxc/{vmid}/vncwebsocket"
|
||||
f"?port={port}&vncticket={quote(vncticket, safe='')}"
|
||||
)
|
||||
|
||||
@@ -3121,6 +3217,8 @@ async def terminal_websocket_fixed(
|
||||
[
|
||||
asyncio.create_task(client_to_proxmox()),
|
||||
asyncio.create_task(proxmox_to_client()),
|
||||
# Closes the bridge once the session/permission is gone.
|
||||
asyncio.create_task(watch_ws_access(user.id, ws_session, _still_allowed)),
|
||||
],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -3157,7 +3255,7 @@ async def serial_websocket(
|
||||
server_id: int,
|
||||
node: str,
|
||||
vmid: int,
|
||||
token: str = Query(None), # JWT панели (?token=)
|
||||
ticket: str = Query(None), # WS-тикет панели (POST /api/auth/ws-ticket)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""WebSocket serial-консоль для VM (qemu) через Proxmox termproxy.
|
||||
@@ -3169,14 +3267,15 @@ async def serial_websocket(
|
||||
import httpx
|
||||
from urllib.parse import quote
|
||||
|
||||
user = authenticate_ws_token(token, db)
|
||||
user, ws_session = authenticate_ws_session(ticket, db)
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
if not PermissionEngine.has_permission(user, "vm:console"):
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
return
|
||||
if not check_vm_access(db, user, server_id, vmid):
|
||||
|
||||
def _still_allowed(db_, u):
|
||||
return PermissionEngine.has_permission(u, "vm:console") and check_vm_access(db_, u, server_id, vmid)
|
||||
|
||||
if not _still_allowed(db, user):
|
||||
await websocket.close(code=4003, reason="Access denied")
|
||||
return
|
||||
|
||||
@@ -3190,14 +3289,14 @@ async def serial_websocket(
|
||||
|
||||
proxmox_ws = None
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=False, timeout=10) as client:
|
||||
async with httpx.AsyncClient(verify=server.verify_ssl, timeout=10) as client:
|
||||
if not server.password:
|
||||
await websocket.close(code=1011, reason="Serial console requires password authentication")
|
||||
return
|
||||
|
||||
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
|
||||
auth_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={"username": auth_username, "password": server.password}
|
||||
)
|
||||
if auth_resp.status_code != 200:
|
||||
@@ -3209,7 +3308,7 @@ async def serial_websocket(
|
||||
csrf_token = auth_data["CSRFPreventionToken"]
|
||||
|
||||
term_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
|
||||
headers={"CSRFPreventionToken": csrf_token},
|
||||
cookies={"PVEAuthCookie": ticket}
|
||||
)
|
||||
@@ -3223,7 +3322,7 @@ async def serial_websocket(
|
||||
port = term_data["port"]
|
||||
|
||||
proxmox_ws_url = (
|
||||
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket"
|
||||
f"wss://{pve_authority(server)}/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket"
|
||||
f"?port={port}&vncticket={quote(vncticket, safe='')}"
|
||||
)
|
||||
|
||||
@@ -3284,6 +3383,8 @@ async def serial_websocket(
|
||||
[
|
||||
asyncio.create_task(client_to_proxmox()),
|
||||
asyncio.create_task(proxmox_to_client()),
|
||||
# Closes the bridge once the session/permission is gone.
|
||||
asyncio.create_task(watch_ws_access(user.id, ws_session, _still_allowed)),
|
||||
],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -3321,7 +3422,7 @@ async def node_shell_websocket(
|
||||
server_id: int,
|
||||
node: str,
|
||||
cmd: str = Query("upgrade"), # upgrade | login
|
||||
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
|
||||
ticket: str = Query(None), # WS-тикет панели (POST /api/auth/ws-ticket)
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -3334,12 +3435,19 @@ async def node_shell_websocket(
|
||||
from urllib.parse import quote
|
||||
|
||||
# --- Authenticate the panel user before opening a root shell -----------
|
||||
user = authenticate_ws_token(token, db)
|
||||
user, ws_session = authenticate_ws_session(ticket, db)
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
if not PermissionEngine.has_permission(user, "node:upgrade"):
|
||||
await websocket.close(code=4003, reason="Permission denied")
|
||||
|
||||
def _still_allowed(db_, u):
|
||||
# WebSocket routes are not covered by the router's enforce_server_visibility.
|
||||
visible = get_visible_server_ids(_NO_WORKSPACE_HEADER, db_, u)
|
||||
return (PermissionEngine.has_permission(u, "node:upgrade")
|
||||
and (visible is None or server_id in visible))
|
||||
|
||||
if not _still_allowed(db, user):
|
||||
await websocket.close(code=4003, reason="Access denied")
|
||||
return
|
||||
if cmd not in _ALLOWED_NODE_SHELL_CMDS:
|
||||
await websocket.close(code=4003, reason="Command not allowed")
|
||||
@@ -3359,14 +3467,14 @@ async def node_shell_websocket(
|
||||
|
||||
try:
|
||||
# Шаг 1: Auth ticket (termproxy требует пароль, не API-токен)
|
||||
async with httpx.AsyncClient(verify=False, timeout=10) as client:
|
||||
async with httpx.AsyncClient(verify=server.verify_ssl, timeout=10) as client:
|
||||
if not server.password:
|
||||
await websocket.close(code=1011, reason="Node shell requires password authentication")
|
||||
return
|
||||
|
||||
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
|
||||
auth_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/access/ticket",
|
||||
f"https://{pve_authority(server)}/api2/json/access/ticket",
|
||||
data={"username": auth_username, "password": server.password}
|
||||
)
|
||||
if auth_resp.status_code != 200:
|
||||
@@ -3380,7 +3488,7 @@ async def node_shell_websocket(
|
||||
|
||||
# Шаг 2: Termproxy на уровне ноды с нужной командой
|
||||
term_resp = await client.post(
|
||||
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/termproxy",
|
||||
f"https://{pve_authority(server)}/api2/json/nodes/{node}/termproxy",
|
||||
headers={"CSRFPreventionToken": csrf_token},
|
||||
cookies={"PVEAuthCookie": ticket},
|
||||
data={"cmd": cmd},
|
||||
@@ -3397,7 +3505,7 @@ async def node_shell_websocket(
|
||||
|
||||
# Шаг 3: WebSocket к Proxmox (node-level vncwebsocket)
|
||||
proxmox_ws_url = (
|
||||
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/vncwebsocket"
|
||||
f"wss://{pve_authority(server)}/api2/json/nodes/{node}/vncwebsocket"
|
||||
f"?port={port}&vncticket={quote(vncticket, safe='')}"
|
||||
)
|
||||
|
||||
@@ -3468,6 +3576,8 @@ async def node_shell_websocket(
|
||||
[
|
||||
asyncio.create_task(client_to_proxmox()),
|
||||
asyncio.create_task(proxmox_to_client()),
|
||||
# Closes the bridge once the session/permission is gone.
|
||||
asyncio.create_task(watch_ws_access(user.id, ws_session, _still_allowed)),
|
||||
],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -3502,11 +3612,15 @@ async def node_shell_websocket(
|
||||
def create_bulk_operation(
|
||||
request: BulkOperationRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:manage"))
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a bulk operation task.
|
||||
Returns task ID for tracking progress.
|
||||
|
||||
Every item is checked like the single-guest endpoint would check it:
|
||||
the action's permission and access to that guest. The queue worker acts
|
||||
with panel credentials, so items it receives must already be authorized.
|
||||
"""
|
||||
# Map action to task type
|
||||
action_to_task_type = {
|
||||
@@ -3525,10 +3639,12 @@ def create_bulk_operation(
|
||||
if not request.items:
|
||||
raise HTTPException(status_code=400, detail="No items selected")
|
||||
|
||||
# For delete action, require higher permission
|
||||
if request.action == 'delete':
|
||||
if not check_permission(current_user, "vm:delete"):
|
||||
raise HTTPException(status_code=403, detail="Delete permission required")
|
||||
perms = _BULK_ACTION_PERMISSIONS[request.action]
|
||||
for perm in {perms[i.vm_type] for i in request.items}:
|
||||
if not check_permission(current_user, perm):
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {perm}")
|
||||
for i in request.items:
|
||||
require_vm_access(db, current_user, i.server_id, i.vmid)
|
||||
|
||||
# For migrate, каждый элемент обязан нести целевую ноду
|
||||
if request.action == 'migrate':
|
||||
@@ -3730,8 +3846,11 @@ def set_vm_owner(
|
||||
|
||||
# Push a real-time event so every connected client refreshes the owner
|
||||
# column without a manual page reload.
|
||||
# The payload carries the new owner's e-mail, so it goes to administrators
|
||||
# and the two owners involved only — not to every connected tenant.
|
||||
try:
|
||||
from ...websocket_manager import broadcast_event
|
||||
from ...websocket_manager import ws_manager, run_async_safe
|
||||
from ...models import Role
|
||||
owner_user = (
|
||||
{
|
||||
"username": target_user.username,
|
||||
@@ -3740,13 +3859,22 @@ def set_vm_owner(
|
||||
}
|
||||
if target_user else None
|
||||
)
|
||||
broadcast_event(
|
||||
"vm_owner_changed",
|
||||
server_id=server_id,
|
||||
vmid=vmid,
|
||||
owner_id=body.user_id,
|
||||
owner_user=owner_user,
|
||||
)
|
||||
payload = {
|
||||
"type": "vm_owner_changed",
|
||||
"server_id": server_id,
|
||||
"vmid": vmid,
|
||||
"owner_id": body.user_id,
|
||||
"owner_user": owner_user,
|
||||
}
|
||||
admin_ids = {
|
||||
uid for (uid,) in db.query(User.id)
|
||||
.outerjoin(Role, Role.id == User.role_id)
|
||||
.filter(User.is_active == True) # noqa: E712
|
||||
.filter((User.is_admin == True) | (Role.name == "admin")) # noqa: E712
|
||||
}
|
||||
recipients = admin_ids | {uid for uid in (old_owner_id, body.user_id) if uid}
|
||||
for uid in recipients:
|
||||
run_async_safe(ws_manager.send_to_user(uid, payload))
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to broadcast vm_owner_changed: {e}")
|
||||
|
||||
@@ -3763,9 +3891,13 @@ class NotesRequest(BaseModel):
|
||||
description: Optional[str] = ""
|
||||
|
||||
|
||||
# `device` becomes a VM config key: keep it to CD-ROM capable buses.
|
||||
_CDROM_DEVICE = r"^(ide|sata|scsi)\d{1,2}$"
|
||||
|
||||
|
||||
class IsoAttachRequest(BaseModel):
|
||||
volid: str
|
||||
device: str = "ide2"
|
||||
volid: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*:iso/[^/\s]+$")
|
||||
device: str = Field(default="ide2", pattern=_CDROM_DEVICE)
|
||||
# Поставить этот ISO первым в порядке загрузки (грузиться с образа: live-CD и т.п.).
|
||||
boot_from_iso: bool = False
|
||||
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
|
||||
@@ -3773,7 +3905,7 @@ class IsoAttachRequest(BaseModel):
|
||||
|
||||
|
||||
class IsoDetachRequest(BaseModel):
|
||||
device: str = "ide2"
|
||||
device: str = Field(default="ide2", pattern=_CDROM_DEVICE)
|
||||
# После извлечения ISO — поставить диск первым в порядке загрузки (грузиться с ОС).
|
||||
boot_from_disk: bool = True
|
||||
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
|
||||
@@ -3849,7 +3981,7 @@ def update_container_notes(
|
||||
|
||||
# -------- ISO mount/unmount (KVM only) --------
|
||||
|
||||
@router.get("/api/{server_id}/node/{node}/isos")
|
||||
@router.get("/api/{server_id}/node/{node}/isos", dependencies=[Depends(enforce_server_visibility)])
|
||||
def list_node_isos(
|
||||
server_id: int,
|
||||
node: str,
|
||||
@@ -4189,6 +4321,8 @@ def control_container(
|
||||
return JSONResponse(content={"status": "success", "action": action_name, "vmid": vmid, "node": node, "upid": upid})
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to execute action")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error controlling container {vmid} on {server.name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -18,13 +18,13 @@ import re
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...db import get_db
|
||||
from ...models import ScriptCatalog, ScriptGitRepo, User
|
||||
from ...services import script_catalog_sync
|
||||
from ...services import script_catalog_sync, script_engine
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -38,6 +38,20 @@ class ParamField(BaseModel):
|
||||
required: bool = False
|
||||
default: Optional[str] = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _check_name(cls, v: str) -> str:
|
||||
# Parameters become `export NAME=...` lines in the rendered script.
|
||||
if not script_engine.is_valid_param_name(v):
|
||||
raise ValueError("Parameter name must match [A-Za-z_][A-Za-z0-9_]*")
|
||||
return v
|
||||
|
||||
|
||||
def _check_interpreter(v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and not script_engine.is_valid_interpreter(v):
|
||||
raise ValueError("Interpreter must be an absolute path like /bin/bash")
|
||||
return v
|
||||
|
||||
|
||||
class ScriptCreateRequest(BaseModel):
|
||||
name: str
|
||||
@@ -49,6 +63,8 @@ class ScriptCreateRequest(BaseModel):
|
||||
content: str
|
||||
params_schema: List[ParamField] = []
|
||||
|
||||
_interpreter = field_validator("interpreter")(_check_interpreter)
|
||||
|
||||
|
||||
class ScriptUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
@@ -59,6 +75,8 @@ class ScriptUpdateRequest(BaseModel):
|
||||
content: Optional[str] = None
|
||||
params_schema: Optional[List[ParamField]] = None
|
||||
|
||||
_interpreter = field_validator("interpreter")(_check_interpreter)
|
||||
|
||||
|
||||
class RepoCreateRequest(BaseModel):
|
||||
url: str
|
||||
@@ -67,6 +85,21 @@ class RepoCreateRequest(BaseModel):
|
||||
metadata_format: str = Field(default="header-comment", pattern="^(header-comment|community-scripts-ct)$")
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, v: str) -> str:
|
||||
return script_catalog_sync.validate_repo_url(v)
|
||||
|
||||
@field_validator("branch")
|
||||
@classmethod
|
||||
def _check_branch(cls, v: str) -> str:
|
||||
return script_catalog_sync.validate_branch(v)
|
||||
|
||||
@field_validator("path_glob")
|
||||
@classmethod
|
||||
def _check_glob(cls, v: str) -> str:
|
||||
return script_catalog_sync.validate_path_glob(v)
|
||||
|
||||
|
||||
@router.get("/api/scripts")
|
||||
def list_scripts(
|
||||
|
||||
@@ -11,12 +11,12 @@ from __future__ import annotations
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...auth import PermissionChecker, check_permission
|
||||
from ...db import SessionLocal, get_db
|
||||
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
|
||||
from ...services import script_engine
|
||||
@@ -66,7 +66,8 @@ def _run_execution_bg(execution_id: int, server_id: int, target_type: str,
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
# Goes into Proxmox API paths (guest agent): plain node names only.
|
||||
node: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
|
||||
vmid: Optional[int] = None
|
||||
vm_type: Optional[str] = Field(default=None, pattern="^(qemu|lxc)$")
|
||||
params: dict = {}
|
||||
@@ -91,20 +92,32 @@ def _can_admin(user: User) -> bool:
|
||||
def execute_script(
|
||||
script_id: int,
|
||||
req: ExecuteRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:execute")),
|
||||
):
|
||||
from ..proxmox._helpers import get_visible_server_ids, require_vm_access
|
||||
|
||||
script = db.get(ScriptCatalog, script_id)
|
||||
if not script:
|
||||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||||
|
||||
server = db.get(ProxmoxServer, req.server_id)
|
||||
if not server:
|
||||
visible = get_visible_server_ids(request, db, current_user) if server else None
|
||||
if not server or (visible is not None and server.id not in visible):
|
||||
raise HTTPException(status_code=404, detail="Сервер не найден")
|
||||
|
||||
if script.target_type == "guest" and (req.vmid is None or not req.node or not req.vm_type):
|
||||
raise HTTPException(status_code=400, detail="Для guest-скрипта нужны node, vmid и vm_type")
|
||||
|
||||
# script:execute alone used to allow running anything anywhere: a tenant
|
||||
# could target other users' guests or get a root shell on the host.
|
||||
if script.target_type == "node":
|
||||
if not check_permission(current_user, "node:manage"):
|
||||
raise HTTPException(status_code=403, detail="Для запуска на ноде нужно право node:manage")
|
||||
else:
|
||||
require_vm_access(db, current_user, server.id, req.vmid)
|
||||
|
||||
# Валидация обязательных параметров по схеме скрипта.
|
||||
schema = script.params_schema or []
|
||||
params = dict(req.params or {})
|
||||
@@ -155,11 +168,12 @@ def list_executions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("script:view")),
|
||||
):
|
||||
q = db.query(ScriptExecution)
|
||||
# Output (up to 200 KB per run) is only needed by the detail endpoint.
|
||||
q = db.query(ScriptExecution).options(defer(ScriptExecution.output))
|
||||
if not _can_admin(current_user):
|
||||
q = q.filter(ScriptExecution.user_id == current_user.id)
|
||||
items = q.order_by(ScriptExecution.started_at.desc()).limit(min(limit, 200)).all()
|
||||
return {"items": [e.to_dict() for e in items]}
|
||||
items = q.order_by(ScriptExecution.started_at.desc()).limit(max(1, min(limit, 200))).all()
|
||||
return {"items": [e.to_dict(with_output=False) for e in items]}
|
||||
|
||||
|
||||
@router.get("/api/scripts/executions/{execution_id}")
|
||||
|
||||
@@ -12,7 +12,10 @@ from typing import Optional
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User, PanelSettings
|
||||
from ..auth import get_current_user, get_current_user_optional, get_password_hash, verify_password, PermissionChecker
|
||||
from ..auth import (get_current_user, get_current_user_optional, get_password_hash, verify_password,
|
||||
PermissionChecker, get_current_session_token)
|
||||
from ..config import utcnow
|
||||
from ..crypto import encrypt_value
|
||||
from ..logging_service import LoggingService
|
||||
from ..i18n import I18nService, t
|
||||
from ..services.logo_service import LogoService
|
||||
@@ -177,6 +180,12 @@ async def update_profile(
|
||||
if data.ssh_public_key is not None:
|
||||
# Validate SSH key format (basic validation)
|
||||
ssh_key = data.ssh_public_key.strip()
|
||||
if "\n" in ssh_key or "\r" in ssh_key:
|
||||
# Written verbatim into authorized_keys / cloud-init: one key per line.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="SSH-ключ должен быть одной строкой"
|
||||
)
|
||||
if ssh_key and not ssh_key.startswith(('ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2', 'ssh-dss')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -213,10 +222,12 @@ async def update_profile(
|
||||
@router.post("/api/change-password")
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
session_token: str = Depends(get_current_session_token),
|
||||
):
|
||||
"""Change user password"""
|
||||
"""Change user password (the profile page uses this endpoint)"""
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(data.current_password, current_user.hashed_password):
|
||||
@@ -242,9 +253,23 @@ async def change_password(
|
||||
detail="Новые пароли не совпадают"
|
||||
)
|
||||
|
||||
# Same password policy as every other password-setting path — this one
|
||||
# used to accept anything of 6+ characters.
|
||||
from ..services.security_service import SecurityService
|
||||
is_valid, errors = SecurityService.validate_password(
|
||||
db, data.new_password, request.cookies.get("language") or current_user.language or "en"
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(errors))
|
||||
|
||||
# Update password
|
||||
current_user.hashed_password = get_password_hash(data.new_password)
|
||||
current_user.last_password_change = utcnow()
|
||||
current_user.require_password_change = False
|
||||
db.commit()
|
||||
|
||||
# End every other session: whoever may have used the old password is out.
|
||||
SecurityService.terminate_all_user_sessions(db, current_user.id, except_token=session_token)
|
||||
|
||||
# Log password change
|
||||
LoggingService.log(
|
||||
@@ -512,7 +537,8 @@ async def update_smtp_settings(
|
||||
updated_fields.append("smtp_user")
|
||||
|
||||
if data.password is not None:
|
||||
set_setting(db, "smtp_password", data.password, "SMTP пароль")
|
||||
# Encrypted at rest like server passwords (read back in notification_channels).
|
||||
set_setting(db, "smtp_password", encrypt_value(data.password), "SMTP пароль")
|
||||
updated_fields.append("smtp_password")
|
||||
|
||||
if data.from_email is not None:
|
||||
@@ -562,7 +588,7 @@ async def update_telegram_settings(
|
||||
updated_fields = []
|
||||
|
||||
if data.telegram_bot_token is not None:
|
||||
set_setting(db, "telegram_bot_token", data.telegram_bot_token, "Токен Telegram бота")
|
||||
set_setting(db, "telegram_bot_token", encrypt_value(data.telegram_bot_token), "Токен Telegram бота")
|
||||
updated_fields.append("telegram_bot_token")
|
||||
|
||||
if updated_fields:
|
||||
@@ -718,11 +744,12 @@ async def set_repository_url(
|
||||
current_user: User = Depends(PermissionChecker("setting:manage"))
|
||||
):
|
||||
"""Set git repository URL for updates"""
|
||||
# Validate URL format
|
||||
if not repository_url.startswith(('http://', 'https://')):
|
||||
# The update pulls, builds and runs this code as root on the host: over
|
||||
# plain http anyone on the path could substitute it.
|
||||
if not repository_url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Repository URL must start with http:// or https://"
|
||||
detail="Repository URL must start with https://"
|
||||
)
|
||||
|
||||
set_setting(db, "git_repository_url", repository_url, "Git repository URL for updates")
|
||||
|
||||
@@ -70,7 +70,13 @@ class SSHKeyResponse(BaseModel):
|
||||
|
||||
|
||||
def _normalize_public_key(value: str) -> str:
|
||||
return value.strip()
|
||||
value = (value or "").strip()
|
||||
# Keys are written verbatim into authorized_keys / cloud-init sshkeys: an
|
||||
# embedded newline would add an extra, unvalidated entry (e.g. with
|
||||
# command=/from= options) next to the one shown in the UI.
|
||||
if "\n" in value or "\r" in value:
|
||||
raise HTTPException(status_code=400, detail="SSH public key must be a single line")
|
||||
return value
|
||||
|
||||
|
||||
def _compute_fingerprint(public_key: str) -> Optional[str]:
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..schemas import (
|
||||
)
|
||||
from ..proxmox import ProxmoxClient
|
||||
from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
from ..api.proxmox._helpers import enforce_server_visibility, get_visible_server_ids, _NO_WORKSPACE_HEADER
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..ipam_service import IPAMService
|
||||
from ..logging_service import LoggingService
|
||||
@@ -180,14 +181,22 @@ def list_os_templates(
|
||||
query = query.filter(OSTemplate.vm_type == vm_type)
|
||||
if active_only:
|
||||
query = query.filter(OSTemplate.is_active == True)
|
||||
# Tenants only see templates of servers they can deploy to (see deploy).
|
||||
visible = get_visible_server_ids(_NO_WORKSPACE_HEADER, db, current_user)
|
||||
if visible is not None:
|
||||
query = query.filter(OSTemplate.server_id.in_(visible))
|
||||
|
||||
os_templates = query.order_by(OSTemplate.sort_order).all()
|
||||
|
||||
# Enrich with group and server info
|
||||
# Enrich with group and server info (two queries, not two per template)
|
||||
group_ids = {t.group_id for t in os_templates if t.group_id is not None}
|
||||
server_ids = {t.server_id for t in os_templates if t.server_id is not None}
|
||||
groups = {g.id: g for g in db.query(OSTemplateGroup).filter(OSTemplateGroup.id.in_(group_ids))} if group_ids else {}
|
||||
servers = {s.id: s for s in db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids))} if server_ids else {}
|
||||
result = []
|
||||
for template in os_templates:
|
||||
group = db.query(OSTemplateGroup).filter(OSTemplateGroup.id == template.group_id).first()
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
group = groups.get(template.group_id)
|
||||
server = servers.get(template.server_id)
|
||||
|
||||
template_dict = {
|
||||
**template.__dict__,
|
||||
@@ -319,7 +328,7 @@ def delete_os_template(
|
||||
|
||||
# ==================== Proxmox Templates Discovery ====================
|
||||
|
||||
@router.get("/api/discover/{server_id}")
|
||||
@router.get("/api/discover/{server_id}", dependencies=[Depends(enforce_server_visibility)])
|
||||
def discover_proxmox_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -389,6 +398,8 @@ def discover_proxmox_templates(
|
||||
'templates': result
|
||||
})
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering templates on {server.name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обнаружения шаблонов: {str(e)}")
|
||||
@@ -582,7 +593,7 @@ def _do_auto_import(server_id: int, username: str):
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/api/auto-import/{server_id}")
|
||||
@router.post("/api/auto-import/{server_id}", dependencies=[Depends(enforce_server_visibility)])
|
||||
def auto_import_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -1050,6 +1061,11 @@ async def deploy_vm_from_template(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == template.server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
# The body names a template, not a server, so the router-level server
|
||||
# check does not apply: a tenant could deploy onto any server.
|
||||
visible = get_visible_server_ids(_NO_WORKSPACE_HEADER, db, current_user)
|
||||
if visible is not None and server.id not in visible:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
cores = deploy_data.cores or template.default_cores
|
||||
memory = deploy_data.memory or template.default_memory
|
||||
|
||||
@@ -4,7 +4,7 @@ Users and Roles Management API
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from loguru import logger
|
||||
@@ -25,6 +25,46 @@ from ..logging_service import LoggingService
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== Privilege guards ====================
|
||||
|
||||
def _is_admin_user(user: User) -> bool:
|
||||
"""Administrator by flag or by the system ``admin`` role."""
|
||||
return bool(user.is_admin or (user.role and user.role.name == "admin"))
|
||||
|
||||
|
||||
def _guard_target(actor: User, target: User) -> None:
|
||||
"""Only administrators may manage administrator accounts.
|
||||
|
||||
Checking just ``is_admin`` let a user holding ``user:update`` reset the
|
||||
password of an account that is admin through its role and log in as it.
|
||||
"""
|
||||
if _is_admin_user(target) and not _is_admin_user(actor):
|
||||
raise HTTPException(status_code=403, detail="Cannot manage an administrator account")
|
||||
|
||||
|
||||
def _guard_permission_grant(actor: User, permissions: Optional[dict]) -> None:
|
||||
"""A non-admin may only grant permissions they hold themselves."""
|
||||
if _is_admin_user(actor):
|
||||
return
|
||||
from ..rbac import PermissionEngine
|
||||
granted = {p for p, enabled in (permissions or {}).items() if enabled}
|
||||
missing = granted - PermissionEngine.get_user_permissions(actor)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Cannot grant permissions you do not have: {', '.join(sorted(missing))}",
|
||||
)
|
||||
|
||||
|
||||
def _guard_role_assignment(actor: User, role: Role) -> None:
|
||||
"""Stop non-admins from handing out the admin role or broader roles."""
|
||||
if _is_admin_user(actor):
|
||||
return
|
||||
if role.name == "admin":
|
||||
raise HTTPException(status_code=403, detail="Only administrators can assign the admin role")
|
||||
_guard_permission_grant(actor, role.permissions)
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
@@ -192,6 +232,7 @@ async def create_role(
|
||||
current_user: User = Depends(PermissionChecker("role:manage"))
|
||||
):
|
||||
"""Create a new role"""
|
||||
_guard_permission_grant(current_user, role_data.permissions)
|
||||
# Check if role name exists
|
||||
existing = db.query(Role).filter(Role.name == role_data.name.lower()).first()
|
||||
if existing:
|
||||
@@ -234,6 +275,12 @@ async def update_role(
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
if not _is_admin_user(current_user):
|
||||
# Editing one's own role (or the admin role) is a direct escalation path.
|
||||
if role.name == "admin" or role.id == current_user.role_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify this role")
|
||||
if role_data.permissions is not None:
|
||||
_guard_permission_grant(current_user, role_data.permissions)
|
||||
|
||||
# Update fields
|
||||
if role_data.display_name is not None:
|
||||
@@ -386,11 +433,15 @@ async def create_user(
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
|
||||
if user_data.is_admin and not _is_admin_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Only admin can create administrators")
|
||||
|
||||
# Validate role
|
||||
if user_data.role_id:
|
||||
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
_guard_role_assignment(current_user, role)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
@@ -451,9 +502,9 @@ async def update_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Prevent editing admin by non-admin
|
||||
if user.is_admin and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Cannot edit admin user")
|
||||
# Prevent editing admin (flag or admin role) by non-admin
|
||||
_guard_target(current_user, user)
|
||||
password_changed = False
|
||||
|
||||
# Update fields
|
||||
if user_data.email is not None:
|
||||
@@ -474,11 +525,14 @@ async def update_user(
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
user.hashed_password = get_password_hash(user_data.password)
|
||||
user.last_password_change = utcnow()
|
||||
password_changed = True
|
||||
|
||||
if user_data.role_id is not None:
|
||||
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
if role.id != user.role_id:
|
||||
_guard_role_assignment(current_user, role)
|
||||
user.role_id = user_data.role_id
|
||||
|
||||
if user_data.is_active is not None:
|
||||
@@ -494,9 +548,9 @@ async def update_user(
|
||||
if user_data.require_password_change is not None:
|
||||
user.require_password_change = user_data.require_password_change
|
||||
|
||||
if user_data.is_admin is not None:
|
||||
if user_data.is_admin is not None and bool(user_data.is_admin) != bool(user.is_admin):
|
||||
# Only admin can change admin status
|
||||
if not current_user.is_admin:
|
||||
if not _is_admin_user(current_user):
|
||||
raise HTTPException(status_code=403, detail="Only admin can change admin status")
|
||||
# Cannot remove own admin status
|
||||
if user_id == current_user.id and not user_data.is_admin:
|
||||
@@ -506,6 +560,10 @@ async def update_user(
|
||||
user.updated_at = utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# Whoever knew the old password must not stay logged in.
|
||||
if password_changed and user_id != current_user.id:
|
||||
SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
@@ -556,6 +614,7 @@ async def delete_user(
|
||||
# Cannot delete admin
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
_guard_target(current_user, user)
|
||||
|
||||
username = user.username
|
||||
|
||||
@@ -843,6 +902,7 @@ async def reset_user_password(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_guard_target(current_user, user)
|
||||
|
||||
# Validate password
|
||||
lang = request.cookies.get("language", "en")
|
||||
@@ -888,6 +948,7 @@ async def unlock_user(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_guard_target(current_user, user)
|
||||
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
@@ -924,6 +985,7 @@ async def admin_disable_two_factor(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_guard_target(current_user, user)
|
||||
|
||||
if not user.two_factor_enabled and not user.two_factor_secret:
|
||||
return {"message": "2FA is not enabled for this user"}
|
||||
@@ -961,6 +1023,7 @@ async def terminate_user_sessions(
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_guard_target(current_user, user)
|
||||
|
||||
count = SecurityService.terminate_all_user_sessions(db, user_id)
|
||||
|
||||
@@ -1176,7 +1239,7 @@ async def terminate_all_sessions(
|
||||
|
||||
@router.get("/api/security/events")
|
||||
async def list_security_events(
|
||||
limit: int = 50,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("setting:manage"))
|
||||
):
|
||||
|
||||
@@ -16,6 +16,7 @@ pct/pvesm/pveam/vzdump). Прогресс отдаётся через callback o
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@@ -75,6 +76,9 @@ rm -f /opt/app/docker-compose.yml
|
||||
"""
|
||||
|
||||
|
||||
_PVE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoldenSpec:
|
||||
server_id: int
|
||||
@@ -102,6 +106,11 @@ class GoldenResult:
|
||||
|
||||
def build_golden_template(db: Session, spec: GoldenSpec,
|
||||
on_step: StepCb = _noop) -> GoldenResult:
|
||||
# Interpolated into root shell commands below — accept plain PVE ids only.
|
||||
for label, value in (("template_storage", spec.template_storage),
|
||||
("rootfs_storage", spec.rootfs_storage), ("bridge", spec.bridge)):
|
||||
if not _PVE_ID_RE.match(value or ""):
|
||||
raise PocError(f"Недопустимое значение {label}: {value!r}")
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == spec.server_id).first()
|
||||
if not server:
|
||||
raise PocError(f"Proxmox сервер id={spec.server_id} не найден")
|
||||
@@ -118,7 +127,7 @@ def build_golden_template(db: Session, spec: GoldenSpec,
|
||||
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
raise PocError("SSH к ноде не установлен (нужен root-доступ к ноде)")
|
||||
raise PocError(ssh.last_error or "SSH к ноде не установлен (нужен root-доступ к ноде)")
|
||||
|
||||
vmid: Optional[int] = None
|
||||
created_ct = False
|
||||
|
||||
@@ -31,7 +31,7 @@ from sqlalchemy.orm import Session
|
||||
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid
|
||||
from ..config import settings
|
||||
from ..models import ProxmoxServer
|
||||
from ..ssh_client import SSHClient
|
||||
from ..ssh_client import SSHClient, write_file_commands
|
||||
|
||||
# Docker-сеть, на которую ссылаются compose-файлы Runtipi как external.
|
||||
TIPI_NETWORK = "tipi_main_network"
|
||||
@@ -570,10 +570,11 @@ def _push_file(ssh: SSHClient, vmid: int, content, dest: str, *,
|
||||
content — str (utf-8) или bytes (бинарные файлы каталога).
|
||||
"""
|
||||
raw = content.encode("utf-8") if isinstance(content, str) else content
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
tmp = f"/tmp/appstore_{vmid}_{abs(hash(dest)) % 100000}"
|
||||
# b64 состоит из [A-Za-z0-9+/=], безопасно внутри одинарных кавычек.
|
||||
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp}", what=f"write {dest} on node")
|
||||
# Random name: .env carries app secrets, a guessable /tmp path could be
|
||||
# pre-planted as a symlink on the node.
|
||||
tmp = f"/tmp/appstore_{vmid}_{secrets.token_hex(8)}"
|
||||
for cmd in write_file_commands(tmp, raw):
|
||||
_run(ssh, cmd, what=f"write {dest} on node")
|
||||
perms_arg = f" --perms {perms}" if perms else ""
|
||||
_run(ssh, f"pct push {vmid} {tmp} {dest}{perms_arg}", what=f"pct push {dest}")
|
||||
_run(ssh, f"rm -f {tmp}", what="cleanup temp")
|
||||
@@ -654,8 +655,14 @@ def _env_file_value(v) -> str:
|
||||
return s
|
||||
|
||||
|
||||
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _env_file_content(env: Dict[str, str]) -> str:
|
||||
return "".join(f"{k}={_env_file_value(v)}\n" for k, v in env.items())
|
||||
# A key with '=' or a newline would smuggle extra variables into .env.
|
||||
return "".join(
|
||||
f"{k}={_env_file_value(v)}\n" for k, v in env.items() if _ENV_KEY_RE.match(str(k))
|
||||
)
|
||||
|
||||
|
||||
def _wait_ip(ssh: SSHClient, vmid: int, timeout: int) -> Optional[str]:
|
||||
@@ -753,7 +760,7 @@ def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> Inst
|
||||
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
raise PocError("SSH к ноде не установлен (нужен root-доступ к ноде для pct exec/push)")
|
||||
raise PocError(ssh.last_error or "SSH к ноде не установлен (нужен root-доступ к ноде для pct exec/push)")
|
||||
try:
|
||||
on_step("Ожидание IP-адреса...", 55)
|
||||
# Статический IP из IPAM известен заранее — подтверждаем через контейнер,
|
||||
@@ -839,7 +846,7 @@ def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
|
||||
"""
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
raise PocError("SSH к ноде не установлен (нужен root-доступ для pct exec/push)")
|
||||
raise PocError(ssh.last_error or "SSH к ноде не установлен (нужен root-доступ для pct exec/push)")
|
||||
try:
|
||||
on_step("Доставка нового docker-compose.yml...", 45)
|
||||
_run(ssh, _pct(vmid, f"mkdir -p {APP_DIR} {APP_DATA_DIR}"), what="mkdir app dirs")
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
Authentication module with enhanced security
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional, Tuple
|
||||
from functools import wraps
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
@@ -136,13 +140,12 @@ def get_current_session_token(
|
||||
|
||||
|
||||
def authenticate_ws_token(token: Optional[str], db: Session) -> Optional[User]:
|
||||
"""Authenticate a WebSocket connection from a JWT passed as a query parameter.
|
||||
"""Authenticate a raw JWT (e.g. from an Authorization header).
|
||||
|
||||
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.
|
||||
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. WebSocket handshakes do not
|
||||
use it: they authenticate with a ticket (:func:`authenticate_ws`).
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
@@ -171,6 +174,99 @@ def authenticate_ws_token(token: Optional[str], db: Session) -> Optional[User]:
|
||||
return user
|
||||
|
||||
|
||||
# WebSocket tickets. The session JWT used to travel in ?token= on WebSocket
|
||||
# URLs, where it ends up in proxy logs and browser history. The client now
|
||||
# trades it for an opaque ticket (POST /api/auth/ws-ticket) right before each
|
||||
# connect; the ticket is single use, short-lived and bound to the user and
|
||||
# session. In-memory: the backend runs one uvicorn worker.
|
||||
_WS_TICKET_TTL = 30.0
|
||||
_ws_tickets: dict = {}
|
||||
_ws_tickets_lock = threading.Lock()
|
||||
|
||||
|
||||
def issue_ws_ticket(user_id: int, session_token: Optional[str]) -> str:
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
now = time.monotonic()
|
||||
with _ws_tickets_lock:
|
||||
for key in [k for k, v in _ws_tickets.items() if v[2] < now]:
|
||||
del _ws_tickets[key]
|
||||
_ws_tickets[ticket] = (user_id, session_token, now + _WS_TICKET_TTL)
|
||||
return ticket
|
||||
|
||||
|
||||
def authenticate_ws(ticket: Optional[str], db: Session) -> Optional[User]:
|
||||
"""Authenticate a WebSocket handshake from its ``?ticket=``.
|
||||
|
||||
Redeems the ticket (once) and returns its active, unlocked user, or
|
||||
``None`` on any failure. Never raises — callers close the socket instead.
|
||||
"""
|
||||
return authenticate_ws_session(ticket, db)[0]
|
||||
|
||||
|
||||
def authenticate_ws_session(ticket: Optional[str], db: Session) -> Tuple[Optional[User], Optional[str]]:
|
||||
"""Like :func:`authenticate_ws`, also returning the session token behind
|
||||
the ticket — for :func:`watch_ws_access` on long-lived sockets."""
|
||||
if not ticket:
|
||||
return None, None
|
||||
with _ws_tickets_lock:
|
||||
entry = _ws_tickets.pop(ticket, None)
|
||||
if not entry or entry[2] < time.monotonic():
|
||||
return None, None
|
||||
user_id, session_token, _ = entry
|
||||
|
||||
# The session may have been logged out or terminated since issuance.
|
||||
if session_token:
|
||||
from .services.security_service import SecurityService
|
||||
if not SecurityService.validate_session(db, session_token):
|
||||
return None, None
|
||||
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
|
||||
if user is None or not user.is_active or user.is_locked():
|
||||
return None, None
|
||||
return user, session_token
|
||||
|
||||
|
||||
_WS_RECHECK_SECONDS = 30.0
|
||||
|
||||
|
||||
def ws_access_still_valid(user_id: int, session_token: Optional[str],
|
||||
allowed: Optional[Callable[[Session, User], bool]] = None) -> bool:
|
||||
from .db import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if session_token:
|
||||
from .services.security_service import SecurityService
|
||||
# Read-only: validate_session would slide the expiry, so an idle
|
||||
# tab with an open socket would never time out.
|
||||
if not SecurityService.is_session_live(db, session_token):
|
||||
return False
|
||||
user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
|
||||
if user is None or not user.is_active or user.is_locked():
|
||||
return False
|
||||
return bool(allowed(db, user)) if allowed else True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def watch_ws_access(user_id: int, session_token: Optional[str],
|
||||
allowed: Optional[Callable[[Session, User], bool]] = None,
|
||||
interval: float = _WS_RECHECK_SECONDS) -> None:
|
||||
"""Return once the user may no longer use a long-lived WebSocket.
|
||||
|
||||
Access was checked only at the handshake: a root console stayed open after
|
||||
logout, a password change, a terminated session, an account lock or a
|
||||
revoked permission. Run it next to the bridge tasks and close on return.
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
ok = await asyncio.to_thread(ws_access_still_valid, user_id, session_token, allowed)
|
||||
except Exception:
|
||||
continue # transient DB error: try again next round
|
||||
if not ok:
|
||||
return
|
||||
|
||||
|
||||
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"):
|
||||
@@ -225,26 +321,41 @@ def _validate_ip(ip: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_proxy_peer(ip: Optional[str]) -> bool:
|
||||
"""Whether a direct peer may be a reverse proxy whose headers we trust."""
|
||||
import ipaddress
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip or "")
|
||||
except ValueError:
|
||||
return False
|
||||
return addr.is_private or addr.is_loopback
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Get client IP from request, handling proxies.
|
||||
|
||||
Only X-Forwarded-For / X-Real-IP values that parse as valid IP addresses
|
||||
are trusted, preventing header-injection / IP-spoofing attacks.
|
||||
Proxy headers are honoured only when the direct peer is a private/loopback
|
||||
address (the bundled nginx on the docker network); a client reaching the
|
||||
API directly cannot spoof its address. X-Real-IP is preferred — nginx sets
|
||||
it to $remote_addr. For X-Forwarded-For the right-most entry is used: it is
|
||||
the one appended by our proxy, while left-most entries are client-supplied
|
||||
($proxy_add_x_forwarded_for keeps them), so trusting the first one let
|
||||
anyone rotate fake IPs past brute-force blocking and poison the audit log.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
candidate = forwarded.split(",")[0].strip()
|
||||
if _validate_ip(candidate):
|
||||
return candidate
|
||||
peer = request.client.host if request.client else None
|
||||
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if real_ip and _validate_ip(real_ip):
|
||||
return real_ip
|
||||
if _is_proxy_peer(peer):
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if real_ip and _validate_ip(real_ip):
|
||||
return real_ip
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
candidate = forwarded.split(",")[-1].strip()
|
||||
if _validate_ip(candidate):
|
||||
return candidate
|
||||
|
||||
return "unknown"
|
||||
return peer or "unknown"
|
||||
|
||||
|
||||
# Optional auth - allows both authenticated and anonymous access
|
||||
|
||||
@@ -53,11 +53,18 @@ class Settings(BaseSettings):
|
||||
# Default False preserves legacy behaviour (warn & accept); enable in production
|
||||
# once your servers' host keys are in known_hosts.
|
||||
SSH_REJECT_UNKNOWN_HOSTS: bool = Field(default=False, env="SSH_REJECT_UNKNOWN_HOSTS")
|
||||
# Without strict mode, node host keys are pinned on first use (TOFU) in this
|
||||
# file; default: <APPSTORE_DATA_DIR>/../ssh_known_hosts (the data volume).
|
||||
SSH_KNOWN_HOSTS_FILE: Optional[str] = Field(default=None, env="SSH_KNOWN_HOSTS_FILE")
|
||||
|
||||
# Metrics history
|
||||
METRICS_COLLECT_INTERVAL: int = Field(default=15, env="METRICS_COLLECT_INTERVAL")
|
||||
METRICS_RETENTION_DAYS: int = Field(default=30, env="METRICS_RETENTION_DAYS")
|
||||
|
||||
# Public URL of the panel (e.g. https://panel.example.com). Used to build
|
||||
# links in emails (password reset) instead of trusting request headers.
|
||||
PANEL_URL: Optional[str] = Field(default=None, env="PANEL_URL")
|
||||
|
||||
# CORS — comma-separated list of allowed origins; "*" is insecure in production
|
||||
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")
|
||||
|
||||
@@ -111,6 +118,9 @@ class Settings(BaseSettings):
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"case_sensitive": False,
|
||||
# deploy.sh-generated .env files carry keys this class does not model
|
||||
# (e.g. ALLOWED_HOSTS); don't refuse to start because of them.
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -9,6 +9,7 @@ Key generation:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from sqlalchemy import TypeDecorator, String
|
||||
|
||||
@@ -30,7 +31,14 @@ def _get_fernet():
|
||||
)
|
||||
_warned_no_key = True
|
||||
return None
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
return _fernet_for(key.encode() if isinstance(key, str) else key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _fernet_for(key: bytes) -> Fernet:
|
||||
# Building a Fernet decodes/validates the key; every encrypted column read
|
||||
# went through this, so cache the instance per key.
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_value(value: str) -> str:
|
||||
|
||||
@@ -7,12 +7,15 @@ import time
|
||||
import uuid
|
||||
import json
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import parse_qsl
|
||||
from fastapi import Request, Response
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
from loguru import logger
|
||||
|
||||
from .db import SessionLocal
|
||||
from . import db as _db
|
||||
from .auth import get_client_ip
|
||||
from .logging_service import LoggingService
|
||||
|
||||
|
||||
@@ -100,10 +103,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
try:
|
||||
body_bytes = await request.body()
|
||||
if len(body_bytes) <= self.MAX_BODY_SIZE:
|
||||
try:
|
||||
request_body = json.loads(body_bytes)
|
||||
except Exception:
|
||||
request_body = {"_raw": body_bytes.decode('utf-8', errors='replace')[:1000]}
|
||||
request_body = self._parse_body(request.headers.get("content-type", ""), body_bytes)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read request body: {e}")
|
||||
|
||||
@@ -137,60 +137,50 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
should_log = response.status_code >= 400 or duration_ms > 5000
|
||||
break
|
||||
|
||||
# Log to database (async-safe)
|
||||
# Log to database. LoggingService is synchronous (SQLAlchemy commit);
|
||||
# run it in the threadpool so a slow DB write does not stall the event
|
||||
# loop — and with it every WebSocket console and metrics stream.
|
||||
if should_log and (path.startswith("/api/") or path.startswith("/proxmox/") or path.startswith("/ipam/")):
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
error_message = None
|
||||
error_traceback = None
|
||||
if response.status_code >= 400:
|
||||
error_message = f"HTTP {response.status_code}"
|
||||
|
||||
# Determine log level
|
||||
level = LoggingService.DEBUG
|
||||
if response.status_code >= 500:
|
||||
level = LoggingService.ERROR
|
||||
elif response.status_code >= 400:
|
||||
level = LoggingService.WARNING
|
||||
elif duration_ms > 3000:
|
||||
level = LoggingService.WARNING # Slow requests
|
||||
|
||||
# Determine action from path and method
|
||||
action = self._get_action_from_request(request.method, path)
|
||||
|
||||
# Create detailed message
|
||||
message = f"{request.method} {path}"
|
||||
if duration_ms > 1000:
|
||||
message += f" (slow: {duration_ms}ms)"
|
||||
message += f" -> {response.status_code}"
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=level,
|
||||
category=LoggingService.API,
|
||||
action=action,
|
||||
message=message,
|
||||
request_id=request_id,
|
||||
session_id=session_id,
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=path,
|
||||
query_params=query_params,
|
||||
request_body=request_body,
|
||||
response_status=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
success=response.status_code < 400,
|
||||
error_message=error_message,
|
||||
error_traceback=error_traceback
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log API request: {e}")
|
||||
status_code = response.status_code
|
||||
error_message = f"HTTP {status_code}" if status_code >= 400 else None
|
||||
|
||||
# Determine log level
|
||||
level = LoggingService.DEBUG
|
||||
if status_code >= 500:
|
||||
level = LoggingService.ERROR
|
||||
elif status_code >= 400:
|
||||
level = LoggingService.WARNING
|
||||
elif duration_ms > 3000:
|
||||
level = LoggingService.WARNING # Slow requests
|
||||
|
||||
# Create detailed message
|
||||
message = f"{request.method} {path}"
|
||||
if duration_ms > 1000:
|
||||
message += f" (slow: {duration_ms}ms)"
|
||||
message += f" -> {status_code}"
|
||||
|
||||
await run_in_threadpool(
|
||||
self._write_log,
|
||||
level=level,
|
||||
category=LoggingService.API,
|
||||
action=self._get_action_from_request(request.method, path),
|
||||
message=message,
|
||||
request_id=request_id,
|
||||
session_id=session_id,
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=path,
|
||||
query_params=query_params,
|
||||
request_body=request_body,
|
||||
response_status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
success=status_code < 400,
|
||||
error_message=error_message,
|
||||
error_traceback=None,
|
||||
)
|
||||
|
||||
# Add request ID to response headers for debugging
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
@@ -198,30 +188,39 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_ip(ip: str) -> bool:
|
||||
import ipaddress
|
||||
def _write_log(**fields) -> None:
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
db = _db.SessionLocal()
|
||||
try:
|
||||
LoggingService.log(db=db, **fields)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log API request: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_body(content_type: str, body: bytes) -> Optional[dict]:
|
||||
"""Decode a request body into a dict so LoggingService can mask secrets.
|
||||
|
||||
Form posts (e.g. /api/auth/change-password) used to be stored verbatim
|
||||
as ``_raw`` — masking only applies to dict keys, so old and new
|
||||
passwords landed in the audit log in plaintext. Bodies that cannot be
|
||||
turned into key/value pairs are not logged at all.
|
||||
"""
|
||||
if not body:
|
||||
return None
|
||||
ctype = content_type.split(";", 1)[0].strip().lower()
|
||||
try:
|
||||
if ctype == "application/x-www-form-urlencoded":
|
||||
return dict(parse_qsl(body.decode("utf-8", errors="replace"), keep_blank_values=True))
|
||||
data = json.loads(body)
|
||||
return data if isinstance(data, dict) else {"_items": data}
|
||||
except Exception:
|
||||
return {"_omitted": f"{len(body)} bytes ({ctype or 'unknown type'})"}
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract client IP from request, validating proxy headers to prevent spoofing."""
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
candidate = forwarded_for.split(",")[0].strip()
|
||||
if self._is_valid_ip(candidate):
|
||||
return candidate
|
||||
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if real_ip and self._is_valid_ip(real_ip):
|
||||
return real_ip
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
"""Client IP using the same trusted-proxy rules as authentication."""
|
||||
return get_client_ip(request)
|
||||
|
||||
def _get_action_from_request(self, method: str, path: str) -> str:
|
||||
"""Determine action name from HTTP method and path"""
|
||||
|
||||
@@ -39,7 +39,11 @@ class LoggingService:
|
||||
DOCKER = "docker"
|
||||
|
||||
# Sensitive fields to mask in logs
|
||||
SENSITIVE_FIELDS = {'password', 'token', 'secret', 'api_key', 'apikey', 'access_token', 'refresh_token'}
|
||||
SENSITIVE_FIELDS = {'password', 'token', 'secret', 'api_key', 'apikey', 'access_token', 'refresh_token',
|
||||
'private_key', 'credential', 'passphrase'}
|
||||
# Exact key names that are secrets but too generic for substring matching
|
||||
# ("code" is the 2FA / backup code on login and 2FA endpoints).
|
||||
SENSITIVE_EXACT_FIELDS = {'code', 'otp', 'totp'}
|
||||
|
||||
@staticmethod
|
||||
def generate_request_id() -> str:
|
||||
@@ -55,8 +59,9 @@ class LoggingService:
|
||||
if isinstance(data, dict):
|
||||
masked = {}
|
||||
for key, value in data.items():
|
||||
key_lower = key.lower()
|
||||
if any(sensitive in key_lower for sensitive in LoggingService.SENSITIVE_FIELDS):
|
||||
key_lower = str(key).lower()
|
||||
if key_lower in LoggingService.SENSITIVE_EXACT_FIELDS or any(
|
||||
sensitive in key_lower for sensitive in LoggingService.SENSITIVE_FIELDS):
|
||||
masked[key] = "***MASKED***"
|
||||
else:
|
||||
masked[key] = LoggingService.mask_sensitive_data(value, depth + 1)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,6 +32,31 @@ from .language_middleware import LanguageMiddleware
|
||||
from .i18n import I18nService
|
||||
|
||||
|
||||
# Browsers cannot set headers on WebSocket requests, so WS tickets and
|
||||
# console handles travel in the query string (outdated clients may still send
|
||||
# the session JWT as ?token=) — and uvicorn logs WebSocket connections with it
|
||||
# ("WebSocket /ws/tasks?ticket=..." [accepted]).
|
||||
_SECRET_QUERY_RE = re.compile(r"((?:token|ticket|auth_ticket|vncticket|vnc_password)=)[^&\s\"']+")
|
||||
|
||||
|
||||
def _redact_secrets(message: str) -> str:
|
||||
if "=" not in message:
|
||||
return message
|
||||
return _SECRET_QUERY_RE.sub(r"\1***", message)
|
||||
|
||||
|
||||
class _RedactSecretsFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
return True
|
||||
redacted = _redact_secrets(message)
|
||||
if redacted != message:
|
||||
record.msg, record.args = redacted, ()
|
||||
return True
|
||||
|
||||
|
||||
# Configure logging
|
||||
def setup_logging():
|
||||
"""Setup application logging"""
|
||||
@@ -71,9 +97,12 @@ def setup_logging():
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, _redact_secrets(record.getMessage()))
|
||||
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||
# uvicorn's loggers keep their own handlers (propagate=False).
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logging.getLogger(name).addFilter(_RedactSecretsFilter())
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
for noisy_logger in (
|
||||
@@ -109,6 +138,37 @@ def setup_logging():
|
||||
uvicorn_logger.addFilter(_UvicornAccessFilter())
|
||||
|
||||
|
||||
# Placeholder values shipped in .env.example / deploy.sh templates. A panel
|
||||
# started with one of them signs JWTs with a publicly known key, so anyone could
|
||||
# forge an admin token — refuse to start instead.
|
||||
_INSECURE_SECRET_KEYS = {
|
||||
"your-secret-key-here",
|
||||
"your-very-secure-secret-key-change-this-in-production-minimum-32-chars",
|
||||
}
|
||||
|
||||
|
||||
def _load_or_create_secret_key() -> str:
|
||||
"""Read the generated JWT key from the data volume, creating it once."""
|
||||
import secrets
|
||||
key_file = Path(settings.APPSTORE_DATA_DIR).parent / "secret_key"
|
||||
try:
|
||||
if key_file.exists():
|
||||
stored = key_file.read_text().strip()
|
||||
if len(stored) >= 32:
|
||||
return stored
|
||||
key_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
key = secrets.token_hex(32)
|
||||
key_file.write_text(key)
|
||||
try:
|
||||
key_file.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return key
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot persist generated SECRET_KEY to {key_file}: {e} — sessions reset on restart")
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler"""
|
||||
@@ -118,10 +178,19 @@ async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
|
||||
# Security checks
|
||||
if not settings.SECRET_KEY or settings.SECRET_KEY == "your-secret-key-here":
|
||||
logger.error("CRITICAL: SECRET_KEY is missing or uses the default insecure value!")
|
||||
logger.error("Please set a secure SECRET_KEY in your .env file.")
|
||||
raise Exception("Insecure SECRET_KEY")
|
||||
if len(settings.SECRET_KEY or "") < 32 and settings.SECRET_KEY not in _INSECURE_SECRET_KEYS:
|
||||
# Not fatal: refusing to start would take down existing installs on
|
||||
# self-update. Weak keys are brute-forceable offline from any JWT.
|
||||
logger.warning("SECRET_KEY is shorter than 32 characters — generate one with `openssl rand -hex 32`.")
|
||||
if not settings.SECRET_KEY or settings.SECRET_KEY in _INSECURE_SECRET_KEYS:
|
||||
# A placeholder key is public knowledge: anyone could mint an admin
|
||||
# JWT. Swap in a generated key persisted on the data volume rather than
|
||||
# refusing to start (which would brick installs on self-update).
|
||||
settings.SECRET_KEY = _load_or_create_secret_key()
|
||||
logger.error(
|
||||
"SECRET_KEY was missing or a published placeholder — using a generated key "
|
||||
"instead (existing sessions are logged out). Set SECRET_KEY in backend/.env."
|
||||
)
|
||||
|
||||
if not settings.FERNET_KEY:
|
||||
logger.warning("FERNET_KEY is not set. Sensitive fields (like passwords) will be stored as plaintext.")
|
||||
@@ -276,7 +345,8 @@ def create_app() -> FastAPI:
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
# setdefault: an endpoint may set a stricter policy (sandboxed catalog logos).
|
||||
response.headers.setdefault("Content-Security-Policy", (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; "
|
||||
@@ -284,7 +354,7 @@ def create_app() -> FastAPI:
|
||||
"connect-src 'self' wss: ws: https://esm.sh https://api.github.com; "
|
||||
"font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
))
|
||||
if not settings.DEBUG:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
@@ -327,36 +397,31 @@ def create_app() -> FastAPI:
|
||||
|
||||
# WebSocket endpoint for real-time task updates
|
||||
@app.websocket("/ws/tasks")
|
||||
async def websocket_tasks(websocket: WebSocket, token: str = Query(None)):
|
||||
async def websocket_tasks(websocket: WebSocket, ticket: str = Query(None)):
|
||||
"""
|
||||
WebSocket endpoint for live task manager updates.
|
||||
Auth via ?token=<jwt> query parameter (browsers cannot set WS headers).
|
||||
Auth via ?ticket= from POST /api/auth/ws-ticket (browsers cannot set
|
||||
WS headers).
|
||||
"""
|
||||
from .auth import decode_access_token
|
||||
from .auth import authenticate_ws_session, watch_ws_access
|
||||
from .db import SessionLocal
|
||||
from .models import TaskQueue, ProxmoxTask, User
|
||||
from .models import TaskQueue, ProxmoxTask
|
||||
from .websocket_manager import ws_manager
|
||||
from .services.ws_access import authorize_channel, authorize_rrd_request
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
# --- Authenticate --------------------------------------------------
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing token")
|
||||
# authenticate_ws also validates the bound session and the lock
|
||||
# state, so a logged-out / terminated session cannot keep streaming.
|
||||
if not ticket:
|
||||
await websocket.close(code=4001, reason="Missing ticket")
|
||||
return
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise ValueError("No subject in token")
|
||||
except Exception:
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == username, User.is_active == True).first()
|
||||
user, ws_session = authenticate_ws_session(ticket, db)
|
||||
if not user:
|
||||
await websocket.close(code=4001, reason="User not found")
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
user_id = user.id
|
||||
finally:
|
||||
@@ -365,20 +430,37 @@ def create_app() -> FastAPI:
|
||||
# --- Connect -------------------------------------------------------
|
||||
await ws_manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
# Send initial snapshot
|
||||
# The socket outlives the handshake check: drop it once the session is
|
||||
# logged out / terminated or the account is disabled (4001 = re-auth).
|
||||
async def _close_when_revoked() -> None:
|
||||
await watch_ws_access(user_id, ws_session)
|
||||
try:
|
||||
await websocket.close(code=4001, reason="Session ended")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
watchdog = asyncio.create_task(_close_when_revoked())
|
||||
|
||||
# In-flight on-demand requests of this socket: held so they are not
|
||||
# garbage-collected mid-run, cancelled when the client goes away.
|
||||
pending_requests: set = set()
|
||||
|
||||
def _initial_snapshot() -> list:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
bulk = db.query(TaskQueue).filter(TaskQueue.user_id == user_id).order_by(TaskQueue.created_at.desc()).limit(30).all()
|
||||
prox = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == user_id).order_by(ProxmoxTask.created_at.desc()).limit(30).all()
|
||||
return sorted(
|
||||
[t.to_dict() for t in bulk] + [t.to_dict() for t in prox],
|
||||
key=lambda x: x.get("created_at") or "",
|
||||
reverse=True,
|
||||
)[:30]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
all_tasks = sorted(
|
||||
[t.to_dict() for t in bulk] + [t.to_dict() for t in prox],
|
||||
key=lambda x: x.get("created_at") or "",
|
||||
reverse=True,
|
||||
)[:30]
|
||||
try:
|
||||
# Send initial snapshot (DB work off the event loop)
|
||||
all_tasks = await asyncio.to_thread(_initial_snapshot)
|
||||
await websocket.send_text(json.dumps({"type": "task_list", "tasks": all_tasks}, default=str))
|
||||
|
||||
# Keep connection alive – wait for client messages / ping-pong
|
||||
@@ -394,19 +476,35 @@ def create_app() -> FastAPI:
|
||||
msg = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
# e.g. a bare JSON array used to raise on .get() and drop the socket
|
||||
continue
|
||||
msg_type = msg.get("type", "")
|
||||
if msg_type == "ping":
|
||||
await websocket.send_text(json.dumps({"type": "pong"}))
|
||||
elif msg_type == "subscribe":
|
||||
channel = msg.get("channel", "")
|
||||
if channel:
|
||||
ws_manager.subscribe(websocket, channel)
|
||||
if isinstance(channel, str) and 0 < len(channel) <= 200:
|
||||
allowed, payload_filter = await asyncio.to_thread(
|
||||
authorize_channel, SessionLocal, user_id, channel
|
||||
)
|
||||
if not allowed:
|
||||
await websocket.send_text(json.dumps(
|
||||
# not "channel": the client routes any message carrying that key
|
||||
# to the channel handlers, which expect metrics payloads.
|
||||
{"type": "subscribe_denied", "denied_channel": channel}
|
||||
))
|
||||
continue
|
||||
ws_manager.subscribe(websocket, channel, payload_filter)
|
||||
# Immediately send cached payload so client sees data without waiting
|
||||
from .services.metrics_broadcaster import get_cached_metrics
|
||||
_cached = get_cached_metrics(channel)
|
||||
if _cached:
|
||||
try:
|
||||
await websocket.send_text(json.dumps(_cached, default=str))
|
||||
await websocket.send_text(json.dumps(
|
||||
ws_manager.filter_payload(websocket, channel, _cached),
|
||||
default=str,
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
elif msg_type == "unsubscribe":
|
||||
@@ -416,23 +514,36 @@ def create_app() -> FastAPI:
|
||||
elif msg_type == "request":
|
||||
action = msg.get("action", "")
|
||||
request_id = msg.get("request_id", "")
|
||||
if action in ("get_rrd", "get_node_rrd") and request_id:
|
||||
params = msg.get("params") or {}
|
||||
if action in ("get_rrd", "get_node_rrd") and request_id and isinstance(params, dict):
|
||||
from .services.metrics_broadcaster import handle_rrd_request
|
||||
from .db import SessionLocal as _SL
|
||||
asyncio.create_task(
|
||||
if not await asyncio.to_thread(
|
||||
authorize_rrd_request, SessionLocal, user_id, action, params
|
||||
):
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "rrd_response", "request_id": request_id,
|
||||
"data": None, "error": "Access denied",
|
||||
}))
|
||||
continue
|
||||
task = asyncio.create_task(
|
||||
handle_rrd_request(
|
||||
websocket,
|
||||
request_id,
|
||||
action,
|
||||
msg.get("params", {}),
|
||||
_SL,
|
||||
params,
|
||||
SessionLocal,
|
||||
)
|
||||
)
|
||||
pending_requests.add(task)
|
||||
task.add_done_callback(pending_requests.discard)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
for task in list(pending_requests):
|
||||
task.cancel()
|
||||
ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
# Include routers
|
||||
|
||||
@@ -200,7 +200,8 @@ class AppOperation(Base):
|
||||
progress = Column(Integer, nullable=False, default=0)
|
||||
steps_log = Column(JSON, nullable=False, default=list) # [{step, progress, ts}]
|
||||
error_text = Column(Text, nullable=True)
|
||||
user_id = Column(Integer, nullable=True)
|
||||
# Indexed: /api/all-tasks filters by user every few seconds (migration 45).
|
||||
user_id = Column(Integer, nullable=True, index=True)
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ class User(Base):
|
||||
last_password_change = Column(DateTime(timezone=True), nullable=True)
|
||||
require_password_change = Column(Boolean, default=False, nullable=False)
|
||||
two_factor_enabled = Column(Boolean, default=False, nullable=False)
|
||||
two_factor_secret = Column(String(100), nullable=True)
|
||||
# Encrypted at rest (Fernet) — see migration 44; legacy plaintext rows are
|
||||
# still readable (decrypt falls back) until the migration rewrites them.
|
||||
two_factor_secret = Column(EncryptedString(255), nullable=True)
|
||||
# List of SHA-256 hashes of single-use recovery codes (JSON array).
|
||||
two_factor_backup_codes = Column(JSON, nullable=True)
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Notification models for SQLAlchemy
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from backend.app.db import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Notification model"""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
type = Column(String(50), nullable=False) # vm_status, resource_alert, system, etc
|
||||
level = Column(String(20), nullable=False) # critical, warning, info, success
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text)
|
||||
data = Column(JSON) # Additional structured data
|
||||
link = Column(String(500)) # Deep link to related resource
|
||||
source = Column(String(50)) # proxmox, ipam, system, docker
|
||||
source_id = Column(String(100)) # VM ID, Network ID, etc
|
||||
read = Column(Boolean, default=False)
|
||||
read_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True)) # Auto-delete old notifications
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"type": self.type,
|
||||
"level": self.level,
|
||||
"title": self.title,
|
||||
"message": self.message,
|
||||
"data": self.data,
|
||||
"link": self.link,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"read": self.read,
|
||||
"read_at": self.read_at.isoformat() if self.read_at else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
class NotificationPreference(Base):
|
||||
"""User notification preferences"""
|
||||
__tablename__ = "notification_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
enabled = Column(Boolean, default=True)
|
||||
email_enabled = Column(Boolean, default=False)
|
||||
email_critical_only = Column(Boolean, default=True)
|
||||
telegram_enabled = Column(Boolean, default=False)
|
||||
telegram_chat_id = Column(String(100))
|
||||
webhook_url = Column(String(500))
|
||||
notification_levels = Column(JSON, default=["critical", "warning", "info", "success"])
|
||||
notification_types = Column(JSON, default=["vm_status", "resource_alert", "system", "update"])
|
||||
quiet_hours_start = Column(String(5)) # HH:MM format
|
||||
quiet_hours_end = Column(String(5)) # HH:MM format
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"enabled": self.enabled,
|
||||
"email_enabled": self.email_enabled,
|
||||
"email_critical_only": self.email_critical_only,
|
||||
"telegram_enabled": self.telegram_enabled,
|
||||
"telegram_chat_id": self.telegram_chat_id,
|
||||
"webhook_url": self.webhook_url,
|
||||
"notification_levels": self.notification_levels,
|
||||
"notification_types": self.notification_types,
|
||||
"quiet_hours_start": self.quiet_hours_start,
|
||||
"quiet_hours_end": self.quiet_hours_end,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -120,8 +120,8 @@ class ScriptExecution(Base):
|
||||
started_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
def to_dict(self, *, with_output: bool = True) -> dict:
|
||||
data = {
|
||||
"id": self.id,
|
||||
"kind": "script",
|
||||
"script_id": self.script_id,
|
||||
@@ -139,6 +139,9 @@ class ScriptExecution(Base):
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||
}
|
||||
if not with_output:
|
||||
data.pop("output")
|
||||
return data
|
||||
|
||||
def to_task_dict(self) -> dict:
|
||||
"""Представление для общего списка «Задачи» (/api/all-tasks)."""
|
||||
|
||||
@@ -99,7 +99,9 @@ class ProxmoxTask(Base):
|
||||
'description': self.description,
|
||||
'status': self.status,
|
||||
'exit_status': self.exit_status,
|
||||
'log_text': self.log_text,
|
||||
# log_text is deliberately left out: this dict is sent in task lists
|
||||
# (polled every few seconds) and on every WS task_update tick. The
|
||||
# log is served on demand by /api/proxmox-tasks/{id}/log.
|
||||
'progress': self.progress,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
|
||||
@@ -131,13 +131,20 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
password=server.password,
|
||||
verify_ssl=server.verify_ssl,
|
||||
)
|
||||
return cls(
|
||||
client = cls(
|
||||
host=host,
|
||||
user=server.api_user,
|
||||
token_name=server.api_token_name,
|
||||
token_value=server.api_token_value,
|
||||
verify_ssl=server.verify_ssl,
|
||||
)
|
||||
# API calls go through the token, but a password kept from token
|
||||
# auto-provisioning is still needed for root SSH to the node
|
||||
# (pct exec/push in App Store, golden template build, pvecm delnode).
|
||||
# Without it SSH had no credentials at all and failed with
|
||||
# "No authentication methods available".
|
||||
client._password = getattr(server, "password", None) or None
|
||||
return client
|
||||
|
||||
def __init__(self, host: str, user: str = "root@pam", password: str = None,
|
||||
token_name: str = None, token_value: str = None, verify_ssl: bool = True,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
@@ -295,9 +297,12 @@ class ClusterMixin:
|
||||
)
|
||||
try:
|
||||
if not ssh.connect():
|
||||
return {'success': False, 'error': 'SSH connection failed'}
|
||||
return {'success': False, 'error': ssh.last_error or 'SSH connection failed'}
|
||||
|
||||
output, exit_code = ssh.execute(f"pvecm delnode {node_name}", return_exit_code=True)
|
||||
# node_name comes from the API request and runs in a root shell.
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]{0,62}", node_name or ""):
|
||||
return {'success': False, 'error': f'Invalid node name: {node_name!r}'}
|
||||
output, exit_code = ssh.execute(f"pvecm delnode {shlex.quote(node_name)}", return_exit_code=True)
|
||||
ssh.close()
|
||||
|
||||
# "Could not kill node (error = CS_ERR_NOT_EXIST)" — ignorable per Proxmox docs
|
||||
|
||||
@@ -118,7 +118,7 @@ class LxcMixin:
|
||||
)
|
||||
try:
|
||||
if not ssh.connect():
|
||||
return {'success': False, 'error': 'SSH connection to node failed'}
|
||||
return {'success': False, 'error': ssh.last_error or 'SSH connection to node failed'}
|
||||
# printf + chpasswd; shlex.quote защищает от shell-injection
|
||||
entry = shlex.quote(f'{username}:{password}')
|
||||
inner = f"printf '%s\\n' {entry} | chpasswd"
|
||||
|
||||
@@ -4,13 +4,38 @@ Replaces old PermissionChecker with enhanced functionality
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Union
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi import HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .engine import PermissionEngine
|
||||
from .permissions import ScopeType
|
||||
|
||||
|
||||
def _authenticate_request(request: Request, db: Session):
|
||||
"""Resolve the bearer token to an active user, validating its session.
|
||||
|
||||
Decoding the JWT alone would accept tokens of logged-out sessions and
|
||||
disabled or locked accounts.
|
||||
"""
|
||||
from ..auth import authenticate_ws_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = authenticate_ws_token(auth_header[len("Bearer "):].strip(), db)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
class PermissionChecker:
|
||||
"""
|
||||
FastAPI dependency for checking permissions.
|
||||
@@ -48,54 +73,20 @@ class PermissionChecker:
|
||||
def __call__(self, request: Request, db: Session = None):
|
||||
"""Check permissions for current user"""
|
||||
# Import here to avoid circular imports
|
||||
from ..auth import get_current_user
|
||||
from ..db import get_db
|
||||
|
||||
# Get user from request state if available
|
||||
user = getattr(request.state, 'user', None)
|
||||
|
||||
if user is None:
|
||||
# Fall back to standard auth
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from ..auth import decode_access_token
|
||||
from ..models import User
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Get token from header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
|
||||
if db is None:
|
||||
# Get DB session
|
||||
from ..db import SessionLocal
|
||||
db = SessionLocal()
|
||||
own = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).options(joinedload(User.role)).filter(
|
||||
User.username == username
|
||||
).first()
|
||||
user = _authenticate_request(request, own)
|
||||
finally:
|
||||
db.close()
|
||||
own.close()
|
||||
else:
|
||||
user = db.query(User).options(joinedload(User.role)).filter(
|
||||
User.username == username
|
||||
).first()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
user = _authenticate_request(request, db)
|
||||
|
||||
# Check permissions
|
||||
if self.require_all:
|
||||
@@ -152,40 +143,16 @@ class ScopedPermissionChecker:
|
||||
|
||||
def __call__(self, request: Request):
|
||||
"""Check scoped permission"""
|
||||
from ..auth import get_current_user
|
||||
from ..db import SessionLocal
|
||||
from ..models import User
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Get resource ID from path params
|
||||
resource_id = request.path_params.get(self.resource_param)
|
||||
organization_id = request.path_params.get(self.organization_param) if self.organization_param else None
|
||||
workspace_id = request.path_params.get(self.workspace_param) if self.workspace_param else None
|
||||
|
||||
# Get user
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
from ..auth import decode_access_token
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).options(joinedload(User.role)).filter(
|
||||
User.username == username
|
||||
).first()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
user = _authenticate_request(request, db)
|
||||
|
||||
# Check permission with scope
|
||||
if not PermissionEngine.has_permission(
|
||||
|
||||
@@ -278,6 +278,17 @@ class ProxmoxVMResponse(BaseModel):
|
||||
|
||||
# ==================== OS Template Schemas ====================
|
||||
|
||||
# Group icons are rendered as markup in the UI: allow plain text/emoji or a
|
||||
# single empty <i>/<span> carrying CSS classes — nothing that can run script.
|
||||
_GROUP_ICON_RE = re.compile(r'^(?:[^<>&"]{0,50}|<(i|span) class="[\w\s-]{1,150}"></\1>)$')
|
||||
|
||||
|
||||
def _check_group_icon(v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and not _GROUP_ICON_RE.match(v):
|
||||
raise ValueError('icon must be text/emoji or <i class="..."></i>')
|
||||
return v
|
||||
|
||||
|
||||
class OSTemplateGroupBase(BaseModel):
|
||||
"""Base schema for OS Template Group"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Group name (e.g., Ubuntu, Debian)")
|
||||
@@ -289,7 +300,8 @@ class OSTemplateGroupBase(BaseModel):
|
||||
|
||||
class OSTemplateGroupCreate(OSTemplateGroupBase):
|
||||
"""Schema for creating OS Template Group"""
|
||||
pass
|
||||
# Validated on input only, so legacy rows still serialize in responses.
|
||||
_icon = field_validator("icon")(_check_group_icon)
|
||||
|
||||
|
||||
class OSTemplateGroupUpdate(BaseModel):
|
||||
@@ -300,6 +312,8 @@ class OSTemplateGroupUpdate(BaseModel):
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
_icon = field_validator("icon")(_check_group_icon)
|
||||
|
||||
|
||||
class OSTemplateGroupResponse(OSTemplateGroupBase):
|
||||
"""Schema for OS Template Group response"""
|
||||
|
||||
@@ -304,6 +304,11 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
if not server:
|
||||
raise EngineError("Proxmox сервер не найден")
|
||||
|
||||
# Каждое приложение — отдельный LXC владельца: квоты действуют так же, как
|
||||
# при обычном деплое (иначе App Store был обходом лимитов).
|
||||
from .quota_service import check_quota
|
||||
check_quota(db, user.id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
|
||||
|
||||
# Кастомный порт из формы имеет приоритет над портом каталога (app.port).
|
||||
if port is not None and not (1 <= port <= 65535):
|
||||
raise EngineError("Порт должен быть в диапазоне 1..65535")
|
||||
@@ -820,7 +825,7 @@ def get_logs(db, installed_app_id: int, tail: int = 200) -> str:
|
||||
server, client = _client_for(db, ia.server_id)
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
raise EngineError("SSH к ноде не установлен")
|
||||
raise EngineError(ssh.last_error or "SSH к ноде не установлен")
|
||||
try:
|
||||
tail = max(1, min(int(tail or 200), 2000))
|
||||
inner = f"cd {APP_DIR} && docker compose logs --tail {tail} --no-color 2>&1"
|
||||
|
||||
@@ -5,6 +5,7 @@ APScheduler-based service for running scheduled backup jobs defined in the datab
|
||||
Runs vzdump on the configured Proxmox server at the specified cron times.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
@@ -116,7 +117,15 @@ scheduler = BackupScheduler()
|
||||
|
||||
|
||||
async def run_backup_job(job_id: int) -> None:
|
||||
"""Execute a single backup job. Called by APScheduler or manual trigger."""
|
||||
"""Execute a single backup job. Called by APScheduler or manual trigger.
|
||||
|
||||
The work is blocking (DB + Proxmox HTTP per guest); APScheduler runs this
|
||||
coroutine on the application's event loop, so it goes to a worker thread.
|
||||
"""
|
||||
await asyncio.to_thread(_run_backup_job_sync, job_id)
|
||||
|
||||
|
||||
def _run_backup_job_sync(job_id: int) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(BackupJob).filter(BackupJob.id == job_id).first()
|
||||
@@ -143,7 +152,12 @@ async def run_backup_job(job_id: int) -> None:
|
||||
if vmids == ["all"] or vmids == "all":
|
||||
target_vmids = ["all"]
|
||||
else:
|
||||
target_vmids = [int(v) for v in vmids] if vmids else []
|
||||
try:
|
||||
target_vmids = [int(v) for v in vmids] if vmids else []
|
||||
except (TypeError, ValueError):
|
||||
# Left "running" forever otherwise (jobs created before validation).
|
||||
_update_job_status(db, job, "failed", f"Invalid vmids: {vmids!r}")
|
||||
return
|
||||
|
||||
errors = []
|
||||
last_upid = None
|
||||
|
||||
@@ -621,17 +621,35 @@ async def handle_rrd_request(
|
||||
# Entry-point — call from lifespan
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Strong references to the long-running loops. The event loop only keeps weak
|
||||
# references to tasks, so a task nobody holds can be garbage-collected mid-run
|
||||
# and the corresponding metrics stream silently stops.
|
||||
_background_tasks: set = set()
|
||||
|
||||
|
||||
def _spawn(coro, name: str) -> None:
|
||||
task = asyncio.create_task(coro, name=name)
|
||||
_background_tasks.add(task)
|
||||
|
||||
def _done(t: asyncio.Task) -> None:
|
||||
_background_tasks.discard(t)
|
||||
if not t.cancelled() and t.exception() is not None:
|
||||
logger.error(f"[metrics_broadcaster] task {t.get_name()} crashed: {t.exception()!r}")
|
||||
|
||||
task.add_done_callback(_done)
|
||||
|
||||
|
||||
def start_metrics_broadcaster(db_factory) -> None:
|
||||
"""
|
||||
Schedule all metrics broadcasting coroutines as asyncio Tasks.
|
||||
Must be called from within a running asyncio event loop (e.g. lifespan).
|
||||
"""
|
||||
asyncio.create_task(dashboard_metrics_loop(db_factory), name="metrics:dashboard")
|
||||
asyncio.create_task(server_metrics_loop(db_factory), name="metrics:servers")
|
||||
asyncio.create_task(instance_metrics_loop(db_factory), name="metrics:instances")
|
||||
asyncio.create_task(instances_list_metrics_loop(db_factory), name="metrics:instances_list")
|
||||
_spawn(dashboard_metrics_loop(db_factory), "metrics:dashboard")
|
||||
_spawn(server_metrics_loop(db_factory), "metrics:servers")
|
||||
_spawn(instance_metrics_loop(db_factory), "metrics:instances")
|
||||
_spawn(instances_list_metrics_loop(db_factory), "metrics:instances_list")
|
||||
# Import inside function to avoid circular imports
|
||||
from .metrics_collector import metrics_collector_loop, metrics_retention_loop
|
||||
asyncio.create_task(metrics_collector_loop(db_factory), name="metrics:collector")
|
||||
asyncio.create_task(metrics_retention_loop(db_factory), name="metrics:retention")
|
||||
_spawn(metrics_collector_loop(db_factory), "metrics:collector")
|
||||
_spawn(metrics_retention_loop(db_factory), "metrics:retention")
|
||||
logger.info("[metrics_broadcaster] Metrics broadcasting tasks started")
|
||||
|
||||
@@ -111,34 +111,25 @@ def query_instance_metrics(db, server_id, vmid, from_ts, to_ts, nic="all"):
|
||||
start = datetime.fromtimestamp(from_ts, tz=_tz.utc)
|
||||
end = datetime.fromtimestamp(to_ts, tz=_tz.utc)
|
||||
|
||||
base = (db.query(InstanceMetric)
|
||||
.filter(InstanceMetric.server_id == server_id,
|
||||
InstanceMetric.vmid == vmid,
|
||||
InstanceMetric.ts >= start, InstanceMetric.ts <= end)
|
||||
.order_by(InstanceMetric.ts.asc()).all())
|
||||
# Plain column tuples, not ORM entities: a month window is tens of
|
||||
# thousands of rows, and entity construction dominated the request.
|
||||
M = InstanceMetric
|
||||
base = (db.query(M.ts, M.cpu, M.mem, M.maxmem, M.netin_rate, M.netout_rate,
|
||||
M.diskread_rate, M.diskwrite_rate, M.disk_used, M.disk_total,
|
||||
M.iops_read, M.iops_write)
|
||||
.filter(M.server_id == server_id, M.vmid == vmid,
|
||||
M.ts >= start, M.ts <= end)
|
||||
.order_by(M.ts.asc()).all())
|
||||
|
||||
# Distinct NICs seen in the window (for the selector).
|
||||
if nic == "all":
|
||||
# Only fetch distinct dev names — avoids loading all NIC rows.
|
||||
dev_rows = (db.query(InstanceNicMetric.dev)
|
||||
.filter(InstanceNicMetric.server_id == server_id,
|
||||
InstanceNicMetric.vmid == vmid,
|
||||
InstanceNicMetric.ts >= start, InstanceNicMetric.ts <= end)
|
||||
.distinct().all())
|
||||
nics = sorted({r.dev for r in dev_rows})
|
||||
nic_by_ts: dict[int, tuple] = {}
|
||||
else:
|
||||
# Load full NIC rows for the requested dev to build per-ts override map.
|
||||
nic_rows = (db.query(InstanceNicMetric)
|
||||
.filter(InstanceNicMetric.server_id == server_id,
|
||||
InstanceNicMetric.vmid == vmid,
|
||||
InstanceNicMetric.ts >= start, InstanceNicMetric.ts <= end)
|
||||
.order_by(InstanceNicMetric.ts.asc()).all())
|
||||
nics = sorted({r.dev for r in nic_rows})
|
||||
nic_by_ts = {}
|
||||
for r in nic_rows:
|
||||
if r.dev == nic:
|
||||
nic_by_ts[_to_ts(r.ts)] = (r.in_rate, r.out_rate)
|
||||
N = InstanceNicMetric
|
||||
nic_window = (N.server_id == server_id, N.vmid == vmid, N.ts >= start, N.ts <= end)
|
||||
nics = sorted({r.dev for r in db.query(N.dev).filter(*nic_window).distinct().all()})
|
||||
nic_by_ts: dict[int, tuple] = {}
|
||||
if nic != "all":
|
||||
# Per-ts override map for the requested dev only.
|
||||
for r in db.query(N.ts, N.in_rate, N.out_rate).filter(*nic_window, N.dev == nic).all():
|
||||
nic_by_ts[_to_ts(r.ts)] = (r.in_rate, r.out_rate)
|
||||
|
||||
rows = []
|
||||
for m in base:
|
||||
|
||||
@@ -24,6 +24,11 @@ def get_local_time() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
# Stored Fernet-encrypted (api/settings.py, migration 46); decrypt_value
|
||||
# passes legacy plaintext through unchanged.
|
||||
_ENCRYPTED_SETTINGS = {"smtp_password", "telegram_bot_token"}
|
||||
|
||||
|
||||
def get_notification_setting(key: str, default: str = None) -> Optional[str]:
|
||||
"""Get notification setting from database"""
|
||||
from ..db import SessionLocal
|
||||
@@ -32,7 +37,12 @@ def get_notification_setting(key: str, default: str = None) -> Optional[str]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
setting = db.query(PanelSettings).filter(PanelSettings.key == key).first()
|
||||
return setting.value if setting else default
|
||||
if not setting:
|
||||
return default
|
||||
if key in _ENCRYPTED_SETTINGS:
|
||||
from ..crypto import decrypt_value
|
||||
return decrypt_value(setting.value)
|
||||
return setting.value
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -46,6 +46,43 @@ def get_user_usage(db: Session, user_id: int) -> Dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
# DeployTask kinds that end with a new instance (LXC creation uses the default).
|
||||
_CREATING_KINDS = ('deploy', 'clone', 'vm_iso')
|
||||
|
||||
|
||||
def _in_flight_creations(db: Session, owner_id: int) -> int:
|
||||
"""Queued/running creations not yet in vm_instances.
|
||||
|
||||
The instance row appears only after the clone/create finishes (a minute
|
||||
or more), so parallel requests all passed a max_instances check against
|
||||
the same usage. Tasks whose guest is already cached are not counted twice.
|
||||
"""
|
||||
from ..models import DeployTask, InstalledApp
|
||||
rows = (
|
||||
db.query(DeployTask.vmid)
|
||||
.filter(
|
||||
DeployTask.user_id == owner_id,
|
||||
DeployTask.kind.in_(_CREATING_KINDS),
|
||||
DeployTask.status.in_(('pending', 'running')),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# App Store installs: the LXC is created by a background job.
|
||||
rows += (
|
||||
db.query(InstalledApp.vmid)
|
||||
.filter(InstalledApp.owner_id == owner_id, InstalledApp.status == 'installing')
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return 0
|
||||
cached = {
|
||||
v for (v,) in db.query(VMInstance.vmid).filter(
|
||||
VMInstance.owner_id == owner_id, VMInstance.deleted_at.is_(None),
|
||||
)
|
||||
}
|
||||
return sum(1 for (vmid,) in rows if vmid is None or vmid not in cached)
|
||||
|
||||
|
||||
def check_quota(
|
||||
db: Session,
|
||||
owner_id: int,
|
||||
@@ -63,9 +100,10 @@ def check_quota(
|
||||
return
|
||||
|
||||
usage = get_user_usage(db, owner_id)
|
||||
in_flight = _in_flight_creations(db, owner_id)
|
||||
|
||||
checks = [
|
||||
(quota.max_instances, usage["instances"] + 1, "инстансов", "instances"),
|
||||
(quota.max_instances, usage["instances"] + in_flight + 1, "инстансов", "instances"),
|
||||
(quota.max_cores, usage["cores"] + (add_cores or 0), "vCPU (ядер)", "vCPU"),
|
||||
(quota.max_memory_mb, usage["memory_mb"] + (add_memory_mb or 0), "MB памяти", "MB of RAM"),
|
||||
(quota.max_disk_gb, usage["disk_gb"] + (add_disk_gb or 0), "GB диска", "GB of disk"),
|
||||
|
||||
@@ -128,9 +128,43 @@ def _parse_community_scripts_metadata(text: str, fallback_name: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# Remote URLs accepted for script sources. Anything else is rejected before it
|
||||
# reaches git: a value starting with "-" would be parsed as an option
|
||||
# (e.g. --upload-pack=<cmd> runs a command in the panel container), and
|
||||
# transports like ext::/file:: give local command execution or file access.
|
||||
_REPO_URL_RE = re.compile(r"^(https?|ssh|git)://[^\s]+$|^[\w.-]+@[\w.-]+:[^\s]+$")
|
||||
_BRANCH_RE = re.compile(r"^[\w][\w./-]{0,199}$")
|
||||
|
||||
|
||||
def validate_repo_url(url: str) -> str:
|
||||
url = (url or "").strip()
|
||||
if not _REPO_URL_RE.match(url):
|
||||
raise ValueError("URL must be https://, ssh://, git:// or user@host:path")
|
||||
return url
|
||||
|
||||
|
||||
def validate_branch(branch: str) -> str:
|
||||
branch = (branch or "").strip()
|
||||
if not _BRANCH_RE.match(branch) or ".." in branch:
|
||||
raise ValueError("Invalid branch name")
|
||||
return branch
|
||||
|
||||
|
||||
def validate_path_glob(path_glob: str) -> str:
|
||||
"""Glob must stay inside the clone: no absolute paths, no '..' segments."""
|
||||
path_glob = (path_glob or "").strip()
|
||||
norm = path_glob.replace("\\", "/")
|
||||
if not norm or norm.startswith("/") or os.path.isabs(path_glob) or ".." in norm.split("/"):
|
||||
raise ValueError("path_glob must be a relative pattern inside the repository")
|
||||
return path_glob
|
||||
|
||||
|
||||
def _clone_repo(repo: ScriptGitRepo, dest: str) -> None:
|
||||
url = validate_repo_url(repo.url)
|
||||
branch = validate_branch(repo.branch)
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", "--branch", repo.branch, repo.url, dest],
|
||||
["git", "-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never",
|
||||
"clone", "--depth", "1", "--branch", branch, "--", url, dest],
|
||||
check=True, capture_output=True, timeout=120, text=True,
|
||||
)
|
||||
|
||||
@@ -151,15 +185,41 @@ def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
|
||||
repo.last_sync_error = "git clone timeout (>120s)"
|
||||
db.commit()
|
||||
return {"error": repo.last_sync_error, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||||
except ValueError as e:
|
||||
repo.last_sync_error = str(e)
|
||||
db.commit()
|
||||
return {"error": repo.last_sync_error, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||||
|
||||
pattern = os.path.join(tmp_dir, repo.path_glob)
|
||||
try:
|
||||
path_glob = validate_path_glob(repo.path_glob)
|
||||
except ValueError as e:
|
||||
repo.last_sync_error = str(e)
|
||||
db.commit()
|
||||
return {"error": repo.last_sync_error, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
|
||||
|
||||
root = os.path.realpath(tmp_dir)
|
||||
pattern = os.path.join(tmp_dir, path_glob)
|
||||
files = _glob.glob(pattern, recursive=True)
|
||||
|
||||
is_community_scripts = repo.metadata_format == "community-scripts-ct"
|
||||
record_source = "community-scripts" if is_community_scripts else "git"
|
||||
|
||||
# One query instead of one per file.
|
||||
existing_by_slug = {
|
||||
s.slug: s for s in db.query(ScriptCatalog).filter(ScriptCatalog.git_repo_id == repo.id)
|
||||
}
|
||||
|
||||
seen_slugs: List[str] = []
|
||||
for path in files:
|
||||
# A repository may carry symlinks (e.g. to /proc/self/environ) —
|
||||
# reading through them would import panel secrets into the catalog.
|
||||
if os.path.islink(path) or not os.path.isfile(path):
|
||||
skipped += 1
|
||||
continue
|
||||
real = os.path.realpath(path)
|
||||
if os.path.commonpath([root, real]) != root:
|
||||
skipped += 1
|
||||
continue
|
||||
rel_path = os.path.relpath(path, tmp_dir)
|
||||
slug = f"git-{repo.id}-{rel_path}".replace("/", "-").replace("\\", "-")
|
||||
try:
|
||||
@@ -175,7 +235,10 @@ def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
|
||||
else:
|
||||
meta = _parse_script_metadata(content, fallback_name=os.path.basename(rel_path))
|
||||
|
||||
existing = db.query(ScriptCatalog).filter(ScriptCatalog.slug == slug).first()
|
||||
existing = existing_by_slug.get(slug)
|
||||
if existing is None:
|
||||
# Slug may belong to a record created before git_repo_id was set.
|
||||
existing = db.query(ScriptCatalog).filter(ScriptCatalog.slug == slug).first()
|
||||
if existing:
|
||||
existing.name = meta["name"]
|
||||
existing.description = meta["description"]
|
||||
@@ -195,7 +258,7 @@ def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
|
||||
))
|
||||
created += 1
|
||||
seen_slugs.append(slug)
|
||||
db.commit()
|
||||
db.commit()
|
||||
|
||||
# Удалить из этого источника то, что исчезло из репозитория.
|
||||
stale = db.query(ScriptCatalog).filter(
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -26,7 +27,7 @@ from loguru import logger
|
||||
|
||||
from ..models import ProxmoxServer
|
||||
from ..proxmox import ProxmoxClient
|
||||
from ..ssh_client import SSHClient
|
||||
from ..ssh_client import SSHClient, write_file_commands
|
||||
|
||||
_CMD_TIMEOUT = 60
|
||||
|
||||
@@ -55,6 +56,24 @@ def _shell_quote(value: str) -> str:
|
||||
return "'" + str(value).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
_PARAM_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
||||
_INTERPRETER_RE = re.compile(r"^/[A-Za-z0-9_./+-]{1,99}$")
|
||||
|
||||
|
||||
def is_valid_param_name(name: str) -> bool:
|
||||
return bool(_PARAM_NAME_RE.match(name or ""))
|
||||
|
||||
|
||||
def is_valid_interpreter(path: str) -> bool:
|
||||
return bool(_INTERPRETER_RE.match(path or "")) and ".." not in path
|
||||
|
||||
|
||||
def _tmp_name(prefix: str) -> str:
|
||||
# Unpredictable name: a guest user cannot pre-plant a symlink or swap the
|
||||
# file between push and exec, and parallel runs do not collide.
|
||||
return f"/tmp/{prefix}_{secrets.token_hex(8)}"
|
||||
|
||||
|
||||
def render_params(content: str, params: Dict[str, str]) -> str:
|
||||
"""Добавить блок export-переменных перед телом скрипта.
|
||||
|
||||
@@ -65,8 +84,11 @@ def render_params(content: str, params: Dict[str, str]) -> str:
|
||||
return content
|
||||
lines = ["#!/bin/sh", "# --- параметры, подставленные каталогом скриптов ---"]
|
||||
for name, value in params.items():
|
||||
# Имя переменной ограничено тем, что уже провалидировано схемой
|
||||
# (см. api/scripts.py) — здесь только защитное экранирование значения.
|
||||
# Имена приходят и из метаданных git-репозиториев — строка вида
|
||||
# `X;cmd;Y` стала бы командой. Невалидные имена отбрасываем.
|
||||
if not is_valid_param_name(name):
|
||||
logger.warning(f"[script_engine] skipping invalid parameter name {name!r}")
|
||||
continue
|
||||
lines.append(f"export {name}={_shell_quote('' if value is None else value)}")
|
||||
lines.append("# --- конец параметров ---")
|
||||
return "\n".join(lines) + "\n" + content
|
||||
@@ -97,14 +119,17 @@ def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Не удалось подключиться по SSH к {server.name}")
|
||||
error=ssh.last_error or f"Не удалось подключиться по SSH к {server.name}")
|
||||
|
||||
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
tmp = f"/tmp/pve_script_{abs(hash(script)) % 100000}_{int(time.time())}"
|
||||
if not is_valid_interpreter(interpreter):
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Недопустимый интерпретатор: {interpreter!r}")
|
||||
tmp = _tmp_name("pve_script")
|
||||
try:
|
||||
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp} && chmod +x {tmp}",
|
||||
what="write script on node")
|
||||
output, exit_code = ssh.execute(f"{_SCRIPT_ENV} {interpreter} {tmp}",
|
||||
for cmd in write_file_commands(tmp, script.encode("utf-8")):
|
||||
_run(ssh, cmd, what="write script on node")
|
||||
_run(ssh, f"chmod 700 {tmp}", what="write script on node")
|
||||
output, exit_code = ssh.execute(f"{_SCRIPT_ENV} {shlex.quote(interpreter)} {tmp}",
|
||||
return_exit_code=True, timeout=timeout)
|
||||
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
|
||||
except ScriptExecError as e:
|
||||
@@ -122,17 +147,20 @@ def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: in
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Не удалось подключиться по SSH к {server.name}")
|
||||
error=ssh.last_error or f"Не удалось подключиться по SSH к {server.name}")
|
||||
|
||||
b64 = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
||||
tmp_node = f"/tmp/pve_script_{vmid}_{int(time.time())}"
|
||||
tmp_ct = f"/tmp/pve_script_{vmid}.sh"
|
||||
if not is_valid_interpreter(interpreter):
|
||||
return ExecResult(success=False, output="", exit_code=-1,
|
||||
error=f"Недопустимый интерпретатор: {interpreter!r}")
|
||||
vmid = int(vmid)
|
||||
tmp_node = _tmp_name(f"pve_script_{vmid}")
|
||||
tmp_ct = _tmp_name("pve_script") + ".sh"
|
||||
try:
|
||||
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp_node}",
|
||||
what="write script on node")
|
||||
_run(ssh, f"pct push {vmid} {tmp_node} {tmp_ct}", what="pct push script")
|
||||
for cmd in write_file_commands(tmp_node, script.encode("utf-8")):
|
||||
_run(ssh, cmd, what="write script on node")
|
||||
_run(ssh, f"pct push {vmid} {tmp_node} {tmp_ct} --perms 0700", what="pct push script")
|
||||
output, exit_code = ssh.execute(
|
||||
f"pct exec {vmid} -- env {_SCRIPT_ENV} {interpreter} {tmp_ct}",
|
||||
f"pct exec {vmid} -- env {_SCRIPT_ENV} {shlex.quote(interpreter)} {tmp_ct}",
|
||||
return_exit_code=True, timeout=timeout,
|
||||
)
|
||||
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
|
||||
|
||||
@@ -32,29 +32,33 @@ def make_tz_aware(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
class SecurityService:
|
||||
"""Service for security operations"""
|
||||
|
||||
# Cache for security settings
|
||||
_settings_cache: Dict[str, str] = {}
|
||||
_cache_time: datetime = None
|
||||
# Cache for security settings: key -> (stored value or None, cached_at).
|
||||
# Each key carries its own timestamp — a single shared timestamp was
|
||||
# refreshed by every miss, so a frequently-missed key kept every other
|
||||
# cached value alive forever and changes made by other code paths were
|
||||
# never picked up.
|
||||
_settings_cache: Dict[str, Tuple[Optional[str], datetime]] = {}
|
||||
_cache_ttl = 60 # seconds
|
||||
|
||||
# Sliding-session bookkeeping is written at most this often per session.
|
||||
# validate_session runs on every authenticated request; committing an
|
||||
# UPDATE each time turned every API call (and every poll) into a DB write.
|
||||
_SESSION_TOUCH_INTERVAL = 60 # seconds
|
||||
|
||||
@classmethod
|
||||
def get_setting(cls, db: Session, key: str, default: str = None) -> str:
|
||||
"""Get security setting with caching"""
|
||||
# Check cache
|
||||
now = utcnow()
|
||||
if cls._cache_time and (now - cls._cache_time).seconds < cls._cache_ttl:
|
||||
if key in cls._settings_cache:
|
||||
return cls._settings_cache[key]
|
||||
|
||||
# Query from DB
|
||||
setting = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
|
||||
value = setting.value if setting else default
|
||||
|
||||
# Update cache
|
||||
cls._settings_cache[key] = value
|
||||
cls._cache_time = now
|
||||
|
||||
return value
|
||||
cached = cls._settings_cache.get(key)
|
||||
if cached and (now - cached[1]).total_seconds() < cls._cache_ttl:
|
||||
value = cached[0]
|
||||
else:
|
||||
setting = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
|
||||
# Cache the stored value only (None when absent), so callers that
|
||||
# pass different defaults for the same key each get their own.
|
||||
value = setting.value if setting else None
|
||||
cls._settings_cache[key] = (value, now)
|
||||
return default if value is None else value
|
||||
|
||||
@classmethod
|
||||
def get_setting_int(cls, db: Session, key: str, default: int = 0) -> int:
|
||||
@@ -319,15 +323,27 @@ class SecurityService:
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# Update last activity and extend session expiration (sliding expiration)
|
||||
session.last_activity = utcnow()
|
||||
# Get session timeout setting
|
||||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||||
session.expires_at = utcnow() + timedelta(minutes=session_timeout)
|
||||
db.commit()
|
||||
# Update last activity and extend session expiration (sliding expiration),
|
||||
# throttled so steady polling does not write on every request.
|
||||
last = make_tz_aware(session.last_activity)
|
||||
if last is None or (now - last).total_seconds() >= SecurityService._SESSION_TOUCH_INTERVAL:
|
||||
session.last_activity = now
|
||||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||||
session.expires_at = now + timedelta(minutes=session_timeout)
|
||||
db.commit()
|
||||
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def is_session_live(db: Session, token: str) -> bool:
|
||||
"""Read-only validity check: active and not expired, WITHOUT the sliding
|
||||
extension of validate_session — background checks (WebSocket watchdog)
|
||||
must not keep an idle session alive."""
|
||||
session = db.query(ActiveSession).filter(
|
||||
ActiveSession.session_token == token, ActiveSession.is_active == True # noqa: E712
|
||||
).first()
|
||||
return bool(session and make_tz_aware(session.expires_at) >= utcnow())
|
||||
|
||||
@staticmethod
|
||||
def terminate_session(db: Session, token: str) -> bool:
|
||||
"""Terminate a session"""
|
||||
@@ -342,14 +358,17 @@ class SecurityService:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def terminate_all_user_sessions(db: Session, user_id: int) -> int:
|
||||
"""Terminate all sessions for a user"""
|
||||
count = db.query(ActiveSession).filter(
|
||||
def terminate_all_user_sessions(db: Session, user_id: int, except_token: Optional[str] = None) -> int:
|
||||
"""Terminate all sessions for a user (optionally keeping one session alive)"""
|
||||
query = db.query(ActiveSession).filter(
|
||||
and_(
|
||||
ActiveSession.user_id == user_id,
|
||||
ActiveSession.is_active == True
|
||||
)
|
||||
).update({"is_active": False})
|
||||
)
|
||||
if except_token:
|
||||
query = query.filter(ActiveSession.session_token != except_token)
|
||||
count = query.update({"is_active": False}, synchronize_session=False)
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
@@ -387,7 +406,7 @@ class SecurityService:
|
||||
max_attempts = cls.get_setting_int(db, "max_login_attempts", 5)
|
||||
lockout_minutes = cls.get_setting_int(db, "lockout_duration_minutes", 30)
|
||||
|
||||
user.failed_login_attempts += 1
|
||||
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
||||
|
||||
if user.failed_login_attempts >= max_attempts:
|
||||
user.locked_until = utcnow() + timedelta(minutes=lockout_minutes)
|
||||
|
||||
@@ -346,6 +346,36 @@ class TaskQueueProcessor:
|
||||
pass
|
||||
|
||||
|
||||
def _fail_interrupted_operations(db: Session) -> int:
|
||||
"""Deploys, App Store operations and script runs execute in threads of this
|
||||
process; after a restart nothing will ever finish them. Left as
|
||||
pending/running they inflated the active-task counter and the UI polled
|
||||
them forever (deploy tasks are kept in the browser's storage).
|
||||
"""
|
||||
from ..models import AppOperation, DeployTask, InstalledApp, ScriptExecution
|
||||
now = utcnow()
|
||||
reason = 'Interrupted by backend restart'
|
||||
count = 0
|
||||
for t in db.query(DeployTask).filter(DeployTask.status.in_(['pending', 'running'])):
|
||||
t.status, t.error_message, t.completed_at = 'failed', reason, now
|
||||
count += 1
|
||||
for op in db.query(AppOperation).filter(AppOperation.status == 'running'):
|
||||
op.status, op.error_text, op.finished_at = 'failed', reason, now
|
||||
count += 1
|
||||
for ex in db.query(ScriptExecution).filter(ScriptExecution.status == 'running'):
|
||||
ex.status, ex.error_text, ex.finished_at = 'failed', reason, now
|
||||
count += 1
|
||||
# Apps mid-install/update/delete: same terminal states the engine uses on
|
||||
# failure (both allow a retry). 'installing' also held a quota slot.
|
||||
stuck_app = {'installing': 'error', 'deleting': 'error', 'updating': 'update_failed'}
|
||||
for ia in db.query(InstalledApp).filter(InstalledApp.status.in_(list(stuck_app))):
|
||||
ia.status = stuck_app[ia.status]
|
||||
count += 1
|
||||
if count:
|
||||
logger.warning(f"[TASK QUEUE] Marked {count} interrupted deploy/app/script operation(s) as failed")
|
||||
return count
|
||||
|
||||
|
||||
def recover_interrupted_tasks() -> int:
|
||||
"""Пометить задачи, зависшие в статусе 'running' после рестарта бэкенда.
|
||||
|
||||
@@ -364,7 +394,8 @@ def recover_interrupted_tasks() -> int:
|
||||
task.status = 'failed'
|
||||
task.error_message = 'Interrupted by backend restart'
|
||||
task.completed_at = utcnow()
|
||||
if stuck:
|
||||
other = _fail_interrupted_operations(db)
|
||||
if stuck or other:
|
||||
db.commit()
|
||||
for task in stuck:
|
||||
_ws_broadcast(task, "task_update")
|
||||
@@ -372,7 +403,7 @@ def recover_interrupted_tasks() -> int:
|
||||
f"[TASK QUEUE] Task #{task.id} ({task.task_type}) was interrupted "
|
||||
f"by restart — marked as failed"
|
||||
)
|
||||
return len(stuck)
|
||||
return len(stuck) + other
|
||||
except Exception as e:
|
||||
logger.error(f"[TASK QUEUE] Failed to recover interrupted tasks: {e}")
|
||||
try:
|
||||
|
||||
@@ -6,9 +6,12 @@ etc. Backup (recovery) codes are single-use and stored only as SHA-256 hashes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import secrets
|
||||
from typing import List
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
@@ -30,15 +33,40 @@ def build_otpauth_uri(secret: str, username: str, issuer: str = "PVEmanager") ->
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer)
|
||||
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
"""Verify a 6-digit TOTP code against the secret."""
|
||||
# Last accepted time step per user. RFC 6238 §5.2: a code must not be accepted
|
||||
# twice — otherwise a shoulder-surfed or phished code stays usable for the
|
||||
# whole ±1-step window (~90 s). Kept in memory: the panel runs one worker.
|
||||
_last_used_step: Dict[object, int] = {}
|
||||
_last_used_lock = threading.Lock()
|
||||
|
||||
|
||||
def verify_totp(secret: str, code: str, *, replay_key: Optional[object] = None) -> bool:
|
||||
"""Verify a 6-digit TOTP code against the secret.
|
||||
|
||||
With ``replay_key`` (e.g. the user id) a code is accepted only once, and
|
||||
never for a time step older than the last accepted one.
|
||||
"""
|
||||
if not secret or not code:
|
||||
return False
|
||||
code = code.strip().replace(" ", "")
|
||||
if not code.isdigit():
|
||||
return False
|
||||
try:
|
||||
return pyotp.TOTP(secret).verify(code, valid_window=TOTP_VALID_WINDOW)
|
||||
totp = pyotp.TOTP(secret)
|
||||
now = time.time()
|
||||
for offset in range(-TOTP_VALID_WINDOW, TOTP_VALID_WINDOW + 1):
|
||||
at = now + offset * totp.interval
|
||||
if not hmac.compare_digest(totp.at(at), code):
|
||||
continue
|
||||
if replay_key is None:
|
||||
return True
|
||||
step = int(at // totp.interval)
|
||||
with _last_used_lock:
|
||||
if step <= _last_used_step.get(replay_key, -1):
|
||||
return False
|
||||
_last_used_step[replay_key] = step
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
148
backend/app/services/ws_access.py
Normal file
148
backend/app/services/ws_access.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Authorization for /ws/tasks channel subscriptions and on-demand RRD requests.
|
||||
|
||||
Channel names are chosen by the client, so every subscription has to be
|
||||
checked: without it any logged-in user could stream live metrics of other
|
||||
tenants' guests (``instance_metrics:*``), of servers outside their workspace
|
||||
(``server_metrics:*``), or the whole instance list. Subscribing to arbitrary
|
||||
names also made the broadcaster poll Proxmox for whatever server/vmid the
|
||||
client invented.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
ChannelFilter = Callable[[dict], dict]
|
||||
|
||||
# get_visible_server_ids reads the X-Active-Workspace header; a WebSocket
|
||||
# subscription has no per-request header, so evaluate it without one.
|
||||
_NO_HEADERS = SimpleNamespace(headers={})
|
||||
|
||||
# How long a tenant's owned-guest snapshot is reused by the list filter.
|
||||
_OWNED_TTL = 30.0
|
||||
|
||||
|
||||
def _load_user(db, user_id: int):
|
||||
from ..models import User
|
||||
return (
|
||||
db.query(User)
|
||||
.options(joinedload(User.role))
|
||||
.filter(User.id == user_id, User.is_active == True) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _owned_pairs(db, user) -> set:
|
||||
from ..models import VMInstance
|
||||
rows = db.query(VMInstance.server_id, VMInstance.vmid).filter(
|
||||
VMInstance.owner_id == user.id,
|
||||
VMInstance.deleted_at.is_(None),
|
||||
).all()
|
||||
return {(int(s), int(v)) for s, v in rows}
|
||||
|
||||
|
||||
def _instances_list_filter(db_factory, user_id: int, owned: set) -> ChannelFilter:
|
||||
"""Filter for ``instances_list_metrics`` payloads: keep the tenant's guests."""
|
||||
state = {"owned": owned, "at": time.monotonic()}
|
||||
|
||||
def _filter(payload: dict) -> dict:
|
||||
if time.monotonic() - state["at"] > _OWNED_TTL:
|
||||
db = db_factory()
|
||||
try:
|
||||
user = _load_user(db, user_id)
|
||||
state["owned"] = _owned_pairs(db, user) if user else set()
|
||||
except Exception as exc: # keep the last snapshot on DB hiccups
|
||||
logger.debug(f"[ws_access] owned refresh failed for user {user_id}: {exc}")
|
||||
finally:
|
||||
db.close()
|
||||
state["at"] = time.monotonic()
|
||||
data = payload.get("data") or {}
|
||||
items = [
|
||||
it for it in (data.get("items") or [])
|
||||
if (it.get("server_id"), it.get("vmid")) in state["owned"]
|
||||
]
|
||||
return {**payload, "data": {**data, "items": items}}
|
||||
|
||||
return _filter
|
||||
|
||||
|
||||
def authorize_channel(db_factory, user_id: int, channel: str) -> Tuple[bool, Optional[ChannelFilter]]:
|
||||
"""Return (allowed, payload_filter) for a channel subscription."""
|
||||
from ..rbac import PermissionEngine
|
||||
from ..api.proxmox._helpers import (
|
||||
can_view_all_instances, check_vm_access, get_visible_server_ids,
|
||||
)
|
||||
|
||||
db = db_factory()
|
||||
try:
|
||||
user = _load_user(db, user_id)
|
||||
if not user:
|
||||
return False, None
|
||||
|
||||
def has(perm: str) -> bool:
|
||||
return PermissionEngine.has_permission(user, perm)
|
||||
|
||||
if channel == "dashboard_metrics":
|
||||
return has("dashboard:view"), None
|
||||
|
||||
if channel == "instances_list_metrics":
|
||||
if not has("vm:view"):
|
||||
return False, None
|
||||
if can_view_all_instances(user):
|
||||
return True, None
|
||||
return True, _instances_list_filter(db_factory, user_id, _owned_pairs(db, user))
|
||||
|
||||
if channel.startswith("server_metrics:"):
|
||||
# server_metrics:{server_id}_{node}
|
||||
sid_s, _, node = channel.split(":", 1)[1].partition("_")
|
||||
if not node or not (has("server:view") or has("node:view")):
|
||||
return False, None
|
||||
visible = get_visible_server_ids(_NO_HEADERS, db, user)
|
||||
return visible is None or int(sid_s) in visible, None
|
||||
|
||||
if channel.startswith("instance_metrics:"):
|
||||
# instance_metrics:{server_id}_{vmid}_{type}_{node}
|
||||
parts = channel.split(":", 1)[1].split("_", 3)
|
||||
if len(parts) != 4 or parts[2] not in ("qemu", "lxc"):
|
||||
return False, None
|
||||
return has("vm:view") and check_vm_access(db, user, int(parts[0]), int(parts[1])), None
|
||||
|
||||
return False, None
|
||||
except (ValueError, TypeError):
|
||||
return False, None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def authorize_rrd_request(db_factory, user_id: int, action: str, params: dict) -> bool:
|
||||
"""Whether the user may fetch RRD data for the requested node/guest."""
|
||||
from ..rbac import PermissionEngine
|
||||
from ..api.proxmox._helpers import check_vm_access, get_visible_server_ids
|
||||
|
||||
db = db_factory()
|
||||
try:
|
||||
user = _load_user(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
server_id = int(params.get("server_id", 0))
|
||||
if action == "get_rrd":
|
||||
return (
|
||||
PermissionEngine.has_permission(user, "vm:view")
|
||||
and check_vm_access(db, user, server_id, int(params.get("vmid", 0)))
|
||||
)
|
||||
if action == "get_node_rrd":
|
||||
if not (PermissionEngine.has_permission(user, "server:view")
|
||||
or PermissionEngine.has_permission(user, "node:view")):
|
||||
return False
|
||||
visible = get_visible_server_ids(_NO_HEADERS, db, user)
|
||||
return visible is None or server_id in visible
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,10 +1,14 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, Optional, Union
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import paramiko
|
||||
from paramiko.ssh_exception import SSHException, NoValidConnectionsError
|
||||
from paramiko.ssh_exception import SSHException, NoValidConnectionsError, BadHostKeyException
|
||||
import socket
|
||||
from functools import lru_cache
|
||||
import time
|
||||
@@ -15,6 +19,134 @@ logger = logging.getLogger(__name__)
|
||||
ssh_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="ssh_")
|
||||
|
||||
|
||||
# ── Host key pinning (trust on first use) ────────────────────────────────────
|
||||
#
|
||||
# The panel connects to Proxmox nodes as root. Accepting any host key
|
||||
# (WarningPolicy) let anyone who can intercept that traffic pose as the node
|
||||
# and collect the root password / run commands. Strict checking
|
||||
# (SSH_REJECT_UNKNOWN_HOSTS) needs a pre-filled known_hosts that most installs
|
||||
# never set up. TOFU sits in between: the key seen on the first connection is
|
||||
# pinned in a panel-owned known_hosts file on the data volume, and a different
|
||||
# key later is refused until an administrator resets the pin (e.g. after a node
|
||||
# reinstall: "Reset SSH host key" on the server card).
|
||||
|
||||
_known_hosts_lock = threading.Lock()
|
||||
|
||||
|
||||
# sshd runs a command as `sh -c <command>`, and Linux caps one argument at
|
||||
# MAX_ARG_STRLEN (128 KiB): an inline base64 payload above ~96 KiB of data
|
||||
# failed with "Argument list too long". The chunk is a multiple of 4, so each
|
||||
# piece decodes on its own.
|
||||
_B64_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
def write_file_commands(path: str, data: bytes) -> List[str]:
|
||||
"""Shell commands that write ``data`` to ``path`` (mode 0600) via base64.
|
||||
|
||||
Run them in order. ``path`` is inserted verbatim — pass generated names only.
|
||||
"""
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
if not b64:
|
||||
return [f"umask 077 && : > {path}"]
|
||||
cmds = []
|
||||
for i in range(0, len(b64), _B64_CHUNK):
|
||||
redirect = ">" if i == 0 else ">>"
|
||||
# base64 alphabet [A-Za-z0-9+/=] is safe inside single quotes.
|
||||
cmds.append(f"umask 077 && printf '%s' '{b64[i:i + _B64_CHUNK]}' | base64 -d {redirect} {path}")
|
||||
return cmds
|
||||
|
||||
|
||||
def known_hosts_path() -> Path:
|
||||
"""Panel-owned known_hosts file (persistent volume by default)."""
|
||||
from .config import settings
|
||||
if settings.SSH_KNOWN_HOSTS_FILE:
|
||||
return Path(settings.SSH_KNOWN_HOSTS_FILE)
|
||||
return Path(settings.APPSTORE_DATA_DIR).parent / "ssh_known_hosts"
|
||||
|
||||
|
||||
def host_key_entry(hostname: str, port: int = 22) -> str:
|
||||
"""known_hosts host field: bare host for port 22, ``[host]:port`` otherwise."""
|
||||
return hostname if int(port) == 22 else f"[{hostname}]:{port}"
|
||||
|
||||
|
||||
def key_fingerprint(key: paramiko.PKey) -> str:
|
||||
digest = hashlib.sha256(key.asbytes()).digest()
|
||||
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def _load_known_hosts() -> paramiko.HostKeys:
|
||||
keys = paramiko.HostKeys()
|
||||
path = known_hosts_path()
|
||||
if path.is_file():
|
||||
try:
|
||||
keys.load(str(path))
|
||||
except (OSError, SSHException) as e:
|
||||
logger.warning(f"Cannot read pinned SSH host keys from {path}: {e}")
|
||||
return keys
|
||||
|
||||
|
||||
def _save_known_hosts(keys: paramiko.HostKeys) -> None:
|
||||
"""Atomically rewrite the pinned-keys file. Caller holds _known_hosts_lock."""
|
||||
path = known_hosts_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
keys.save(str(tmp))
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def pin_host_key(entry: str, key: paramiko.PKey) -> None:
|
||||
"""Remember ``key`` for ``entry`` (merging with what other threads pinned)."""
|
||||
with _known_hosts_lock:
|
||||
keys = _load_known_hosts()
|
||||
keys.add(entry, key.get_name(), key)
|
||||
_save_known_hosts(keys)
|
||||
|
||||
|
||||
def pinned_host_keys(hostname: str, port: int = 22) -> List[Dict[str, str]]:
|
||||
"""Pinned keys of a host as [{type, fingerprint}] (for the UI)."""
|
||||
found = _load_known_hosts().lookup(host_key_entry(hostname, port)) or {}
|
||||
return [{"type": ktype, "fingerprint": key_fingerprint(k)} for ktype, k in found.items()]
|
||||
|
||||
|
||||
def forget_host_key(hostname: str, port: int = 22) -> int:
|
||||
"""Drop the pinned key(s) of a host; returns how many entries were removed."""
|
||||
entry = host_key_entry(hostname, port)
|
||||
removed = 0
|
||||
with _known_hosts_lock:
|
||||
keys = _load_known_hosts()
|
||||
while True:
|
||||
try:
|
||||
del keys[entry]
|
||||
except KeyError:
|
||||
break
|
||||
removed += 1
|
||||
if removed:
|
||||
_save_known_hosts(keys)
|
||||
# Cached connections were authenticated against the old key.
|
||||
SSHClient.drop_cached_connections(hostname, port)
|
||||
return removed
|
||||
|
||||
|
||||
class _TrustOnFirstUsePolicy(paramiko.MissingHostKeyPolicy):
|
||||
"""Accept and pin an unknown host key; known keys are verified by paramiko."""
|
||||
|
||||
def missing_host_key(self, client, hostname, key):
|
||||
try:
|
||||
pin_host_key(hostname, key)
|
||||
except OSError as e:
|
||||
# Still connect (previous behaviour), but say that nothing is pinned.
|
||||
logger.error(f"SSH: could not pin host key of {hostname} in {known_hosts_path()}: {e}")
|
||||
client.get_host_keys().add(hostname, key.get_name(), key)
|
||||
logger.warning(
|
||||
f"SSH: first connection to {hostname} — trusting and pinning "
|
||||
f"{key.get_name()} key {key_fingerprint(key)}"
|
||||
)
|
||||
|
||||
|
||||
class SSHClient:
|
||||
"""Улучшенный SSH клиент для подключения к серверам с кешированием"""
|
||||
|
||||
@@ -36,6 +168,22 @@ class SSHClient:
|
||||
self.client = None
|
||||
self.connection_key = f"{hostname}:{port}:{username}"
|
||||
self._connected = False
|
||||
# Human-readable reason of the last failed connect() (for API errors).
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def drop_cached_connections(cls, hostname: str, port: int = 22) -> None:
|
||||
"""Close cached connections to a host (any user)."""
|
||||
prefix = f"{hostname}:{port}:"
|
||||
with cls._cache_lock:
|
||||
for key in [k for k in cls._connections if k.startswith(prefix)]:
|
||||
client = cls._connections.pop(key, None)
|
||||
cls._connection_times.pop(key, None)
|
||||
try:
|
||||
if client is not None:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_cached_connection(self) -> Optional[paramiko.SSHClient]:
|
||||
"""Get cached connection if available and valid"""
|
||||
@@ -94,17 +242,21 @@ class SSHClient:
|
||||
try:
|
||||
client = paramiko.SSHClient()
|
||||
client.load_system_host_keys()
|
||||
# Strict host-key checking is opt-in to avoid breaking existing
|
||||
# deployments whose known_hosts isn't populated yet.
|
||||
# Keys pinned on earlier connections (TOFU); a mismatch makes
|
||||
# paramiko raise BadHostKeyException below.
|
||||
for entry, by_type in _load_known_hosts().items():
|
||||
for ktype, key in by_type.items():
|
||||
client.get_host_keys().add(entry, ktype, key)
|
||||
try:
|
||||
from .config import settings
|
||||
strict = settings.SSH_REJECT_UNKNOWN_HOSTS
|
||||
except Exception:
|
||||
strict = False
|
||||
if strict:
|
||||
# Only keys already in known_hosts / pinned are accepted.
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
else:
|
||||
client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
client.set_missing_host_key_policy(_TrustOnFirstUsePolicy())
|
||||
|
||||
connect_kwargs = {
|
||||
'hostname': self.hostname,
|
||||
@@ -132,22 +284,46 @@ class SSHClient:
|
||||
|
||||
self.client = client
|
||||
self._connected = True
|
||||
self.last_error = None
|
||||
self._cache_connection(client)
|
||||
logger.info(f"Successfully connected to {self.hostname} (attempt {attempt + 1})")
|
||||
return True
|
||||
|
||||
|
||||
except BadHostKeyException as e:
|
||||
# Must precede SSHException (its parent): retrying cannot help.
|
||||
self.last_error = (
|
||||
f"SSH host key of {self.hostname} does not match the pinned key "
|
||||
f"(got {key_fingerprint(e.key)}, expected {key_fingerprint(e.expected_key)}). "
|
||||
f"Possible MITM — if the node was reinstalled, reset the SSH host key "
|
||||
f"on the server card."
|
||||
)
|
||||
logger.error(self.last_error)
|
||||
return False
|
||||
except paramiko.AuthenticationException as e:
|
||||
self.last_error = f"SSH authentication failed for {self.hostname}: {e}"
|
||||
logger.error(f"Authentication failed for {self.hostname}: {e}")
|
||||
return False
|
||||
except (SSHException, NoValidConnectionsError, socket.timeout,
|
||||
except (SSHException, NoValidConnectionsError, socket.timeout,
|
||||
socket.error, ConnectionRefusedError) as e:
|
||||
if isinstance(e, SSHException) and "No authentication methods available" in str(e):
|
||||
# No password and no usable key: retrying cannot help.
|
||||
self.last_error = (
|
||||
f"SSH connection to {self.hostname} failed: no credentials for "
|
||||
f"{self.username}@{self.hostname}. Store the root password on the "
|
||||
f"server (it is kept for SSH even with API-token auth) or add the "
|
||||
f"panel's SSH public key to the node's authorized_keys."
|
||||
)
|
||||
logger.error(self.last_error)
|
||||
return False
|
||||
logger.warning(f"Connection attempt {attempt + 1} failed for {self.hostname}: {e}")
|
||||
if attempt == self.max_retries - 1:
|
||||
self.last_error = f"SSH connection to {self.hostname} failed: {e}"
|
||||
logger.error(f"All connection attempts failed for {self.hostname}")
|
||||
return False
|
||||
# Wait before retry
|
||||
time.sleep(2 ** attempt) # Exponential backoff
|
||||
except Exception as e:
|
||||
self.last_error = f"SSH connection to {self.hostname} failed: {e}"
|
||||
logger.error(f"Unexpected error connecting to {self.hostname}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ and supports channel-based subscriptions for metrics streaming.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, List, Set
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
@@ -19,6 +19,9 @@ class ConnectionManager:
|
||||
self._connections: Dict[int, Set[WebSocket]] = {}
|
||||
# channel_name -> set of subscribed WebSocket connections
|
||||
self._channel_subscribers: Dict[str, Set[WebSocket]] = {}
|
||||
# (websocket, channel) -> payload filter applied before sending, used to
|
||||
# trim shared payloads down to what that subscriber may see.
|
||||
self._channel_filters: Dict[tuple, Callable[[dict], dict]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: int) -> None:
|
||||
await websocket.accept()
|
||||
@@ -37,11 +40,16 @@ class ConnectionManager:
|
||||
|
||||
# ── Channel subscription helpers ──────────────────────────────────
|
||||
|
||||
def subscribe(self, websocket: WebSocket, channel: str) -> None:
|
||||
def subscribe(self, websocket: WebSocket, channel: str,
|
||||
payload_filter: Optional[Callable[[dict], dict]] = None) -> None:
|
||||
"""Subscribe a WebSocket connection to a named channel."""
|
||||
if channel not in self._channel_subscribers:
|
||||
self._channel_subscribers[channel] = set()
|
||||
self._channel_subscribers[channel].add(websocket)
|
||||
if payload_filter is not None:
|
||||
self._channel_filters[(websocket, channel)] = payload_filter
|
||||
else:
|
||||
self._channel_filters.pop((websocket, channel), None)
|
||||
logger.debug(f"[WS] Subscribed to channel '{channel}'")
|
||||
|
||||
def unsubscribe(self, websocket: WebSocket, channel: str) -> None:
|
||||
@@ -50,15 +58,22 @@ class ConnectionManager:
|
||||
self._channel_subscribers[channel].discard(websocket)
|
||||
if not self._channel_subscribers[channel]:
|
||||
del self._channel_subscribers[channel]
|
||||
self._channel_filters.pop((websocket, channel), None)
|
||||
logger.debug(f"[WS] Unsubscribed from channel '{channel}'")
|
||||
|
||||
def unsubscribe_all(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket from every channel it is subscribed to."""
|
||||
for channel in list(self._channel_subscribers):
|
||||
self._channel_subscribers[channel].discard(websocket)
|
||||
self._channel_filters.pop((websocket, channel), None)
|
||||
if not self._channel_subscribers[channel]:
|
||||
del self._channel_subscribers[channel]
|
||||
|
||||
def filter_payload(self, websocket: WebSocket, channel: str, data: dict) -> dict:
|
||||
"""Apply the subscriber's payload filter (if any) to *data*."""
|
||||
payload_filter = self._channel_filters.get((websocket, channel))
|
||||
return payload_filter(data) if payload_filter else data
|
||||
|
||||
def has_channel_subscribers(self, channel: str) -> bool:
|
||||
"""Return True if at least one connection is subscribed to *channel*."""
|
||||
return bool(self._channel_subscribers.get(channel))
|
||||
@@ -76,11 +91,18 @@ class ConnectionManager:
|
||||
dead: Set[WebSocket] = set()
|
||||
for ws in list(connections):
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
payload_filter = self._channel_filters.get((ws, channel))
|
||||
if payload_filter is None:
|
||||
await ws.send_text(message)
|
||||
else:
|
||||
await ws.send_text(json.dumps(payload_filter(data), default=str))
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
subscribers = self._channel_subscribers.get(channel)
|
||||
for ws in dead:
|
||||
self._channel_subscribers[channel].discard(ws)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(ws)
|
||||
self._channel_filters.pop((ws, channel), None)
|
||||
if not self._channel_subscribers.get(channel):
|
||||
self._channel_subscribers.pop(channel, None)
|
||||
|
||||
@@ -155,8 +177,9 @@ def run_async_safe(coro) -> None:
|
||||
else:
|
||||
loop.run_until_complete(coro)
|
||||
except RuntimeError:
|
||||
# No event loop yet (e.g. during startup); silently skip
|
||||
pass
|
||||
# No event loop yet (e.g. during startup); silently skip — close the
|
||||
# coroutine so it is not reported as "never awaited".
|
||||
coro.close()
|
||||
|
||||
|
||||
def broadcast_task_update(user_id: int, event_type: str, task_data: dict) -> None:
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
from ..config import utcnow
|
||||
from ..config import utcnow, make_tz_aware
|
||||
|
||||
|
||||
# qmrestore / vma extract emit lines like "progress 47% (read ...)".
|
||||
@@ -461,8 +461,9 @@ class MonitoringWorker:
|
||||
level = "success"
|
||||
logger.info(f"[VM STARTED] Server: {server.name} | VM: {vm['name']} (ID: {vm['vmid']}) is now running")
|
||||
|
||||
# Notify all users (in production, filter by permissions)
|
||||
for user in users:
|
||||
# Admins and the guest's owner only: other tenants
|
||||
# must not learn about (or be spammed by) foreign VMs.
|
||||
for user in self._vm_recipients(db, users, server, vm['vmid']):
|
||||
NotificationService.notify_vm_status(
|
||||
db,
|
||||
user_id=user.id,
|
||||
@@ -492,6 +493,23 @@ class MonitoringWorker:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def _vm_recipients(db, users: List[User], server: ProxmoxServer, vmid: int) -> List[User]:
|
||||
"""Users to notify about a guest: global-view users plus its owner."""
|
||||
from ..api.proxmox._helpers import can_view_all_instances
|
||||
server_ids = {server.id}
|
||||
if getattr(server, 'cluster_name', None):
|
||||
# The cache keys clustered guests by one member server's id.
|
||||
server_ids |= {sid for (sid,) in db.query(ProxmoxServer.id).filter(
|
||||
ProxmoxServer.cluster_name == server.cluster_name)}
|
||||
owner_ids = {oid for (oid,) in db.query(VMInstance.owner_id).filter(
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.server_id.in_(server_ids),
|
||||
VMInstance.deleted_at.is_(None),
|
||||
VMInstance.owner_id.isnot(None),
|
||||
)}
|
||||
return [u for u in users if u.id in owner_ids or can_view_all_instances(u)]
|
||||
|
||||
def run_resource_monitoring(self):
|
||||
"""Monitor resource usage (CPU, RAM, Disk)"""
|
||||
db = SessionLocal()
|
||||
@@ -513,8 +531,11 @@ class MonitoringWorker:
|
||||
vm_key = f"{server.id}:{vm['vmid']}"
|
||||
|
||||
try:
|
||||
# Get VM statistics
|
||||
stats = client.get_vm_stats(vm['node'], vm['vmid'])
|
||||
# The /nodes/{n}/qemu listing already carries cpu/mem/
|
||||
# disk — only fall back to a per-VM call when it doesn't
|
||||
# (was one extra API request per running VM per minute).
|
||||
stats = vm if vm.get('cpu') is not None else (
|
||||
client.get_vm_stats(vm['node'], vm['vmid']) or {})
|
||||
|
||||
# Check CPU usage
|
||||
if 'cpu' in stats and stats['cpu'] is not None:
|
||||
@@ -522,7 +543,7 @@ class MonitoringWorker:
|
||||
if cpu_percent > 80:
|
||||
alert_key = f"{vm_key}:cpu"
|
||||
if self._should_send_alert(alert_key):
|
||||
for user in users:
|
||||
for user in self._vm_recipients(db, users, server, vm['vmid']):
|
||||
NotificationService.notify_resource_alert(
|
||||
db,
|
||||
user_id=user.id,
|
||||
@@ -540,7 +561,7 @@ class MonitoringWorker:
|
||||
if ram_percent > 85:
|
||||
alert_key = f"{vm_key}:ram"
|
||||
if self._should_send_alert(alert_key):
|
||||
for user in users:
|
||||
for user in self._vm_recipients(db, users, server, vm['vmid']):
|
||||
NotificationService.notify_resource_alert(
|
||||
db,
|
||||
user_id=user.id,
|
||||
@@ -558,7 +579,7 @@ class MonitoringWorker:
|
||||
if disk_percent > 90:
|
||||
alert_key = f"{vm_key}:disk"
|
||||
if self._should_send_alert(alert_key):
|
||||
for user in users:
|
||||
for user in self._vm_recipients(db, users, server, vm['vmid']):
|
||||
NotificationService.notify_resource_alert(
|
||||
db,
|
||||
user_id=user.id,
|
||||
@@ -643,10 +664,12 @@ class MonitoringWorker:
|
||||
# Group servers by their node set
|
||||
server_nodes = {} # server_id -> set of node names
|
||||
cluster_map = {} # server_id -> cluster_id (first server_id in cluster)
|
||||
clients_by_server = {} # server_id -> ProxmoxClient, reused by the collect loop
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
client = self._create_proxmox_client(server)
|
||||
clients_by_server[server.id] = client
|
||||
if client.is_connected():
|
||||
nodes = client.get_nodes()
|
||||
node_names = frozenset([n.get('node', '') for n in nodes if n.get('node')])
|
||||
@@ -681,7 +704,7 @@ class MonitoringWorker:
|
||||
# Collect all VMs from all servers with proper dedup
|
||||
for server in servers:
|
||||
try:
|
||||
client = self._create_proxmox_client(server)
|
||||
client = clients_by_server.get(server.id) or self._create_proxmox_client(server)
|
||||
|
||||
if not client.is_connected():
|
||||
logger.warning(f"[VM SYNC] Server {server.name} not connected, skipping")
|
||||
@@ -746,6 +769,14 @@ class MonitoringWorker:
|
||||
status_changes = [] # list of (server_id, vmid, node, old_status, new_status)
|
||||
# Track newly-created VMs for WebSocket broadcast
|
||||
created_vms = [] # list of (server_id, vmid, node, name, vm_type)
|
||||
|
||||
# One query for every cached row of the synced servers instead of a
|
||||
# SELECT per guest every 10 seconds.
|
||||
sync_server_ids = {sid for sid, _, _ in all_vms_data}
|
||||
existing_rows = {}
|
||||
if sync_server_ids:
|
||||
for row in db.query(VMInstance).filter(VMInstance.server_id.in_(sync_server_ids)):
|
||||
existing_rows.setdefault((row.server_id, row.vmid), row)
|
||||
|
||||
# Upsert all VMs to database
|
||||
for server_id, vm_data, vm_type in all_vms_data:
|
||||
@@ -813,10 +844,7 @@ class MonitoringWorker:
|
||||
new_status = vm_data.get('status', 'unknown')
|
||||
|
||||
# Find existing by (server_id, vmid)
|
||||
existing = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid
|
||||
).first()
|
||||
existing = existing_rows.get((server_id, vmid))
|
||||
|
||||
if existing:
|
||||
# Detect status change for WS broadcast
|
||||
@@ -844,7 +872,11 @@ class MonitoringWorker:
|
||||
# IPAM and detected IPs take priority; otherwise preserve existing
|
||||
if ip_address:
|
||||
existing.ip_address = ip_address
|
||||
existing.last_sync_at = sync_time
|
||||
# last_sync_at alone changing would UPDATE every row on
|
||||
# every 10 s tick; refresh it at most once a minute.
|
||||
last = make_tz_aware(existing.last_sync_at)
|
||||
if last is None or (sync_time - last).total_seconds() >= 60 or db.is_modified(existing):
|
||||
existing.last_sync_at = sync_time
|
||||
existing.deleted_at = None
|
||||
else:
|
||||
# Create new entry
|
||||
@@ -865,6 +897,7 @@ class MonitoringWorker:
|
||||
last_sync_at=sync_time
|
||||
)
|
||||
db.add(new_vm)
|
||||
existing_rows[(server_id, vmid)] = new_vm
|
||||
created_vms.append((server_id, vmid, node_name, vm_name, vm_type))
|
||||
|
||||
except Exception as e:
|
||||
@@ -947,12 +980,13 @@ class MonitoringWorker:
|
||||
from app.websocket_manager import ws_manager, run_async_safe
|
||||
for srv_id, vmid, node_name, vm_name, vm_type in deleted_broadcasts:
|
||||
logger.info(f"[VM SYNC] VM deleted: server={srv_id} vmid={vmid} name={vm_name}")
|
||||
# Broadcast goes to every connected user: no guest
|
||||
# names (other tenants' data), ids are enough to refetch.
|
||||
run_async_safe(ws_manager.broadcast({
|
||||
"type": "vm_deleted",
|
||||
"server_id": srv_id,
|
||||
"vmid": vmid,
|
||||
"node": node_name,
|
||||
"name": vm_name,
|
||||
"vm_type": vm_type,
|
||||
}))
|
||||
except Exception as _we:
|
||||
@@ -969,7 +1003,6 @@ class MonitoringWorker:
|
||||
"server_id": srv_id,
|
||||
"vmid": vmid,
|
||||
"node": node_name,
|
||||
"name": vm_name,
|
||||
"vm_type": vm_type,
|
||||
}))
|
||||
except Exception as _we:
|
||||
@@ -1208,10 +1241,18 @@ monitoring_worker = MonitoringWorker()
|
||||
|
||||
def start_monitoring_worker():
|
||||
"""Start background monitoring tasks using APScheduler"""
|
||||
from datetime import datetime as _dt
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
# coalesce: a job that fell behind (slow Proxmox, GC pause) runs once instead
|
||||
# of firing every missed tick back-to-back; max_instances=1 keeps a slow
|
||||
# sync from overlapping itself.
|
||||
scheduler = BackgroundScheduler(job_defaults={
|
||||
'coalesce': True,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': 30,
|
||||
})
|
||||
|
||||
# Server availability check - every 30 seconds
|
||||
# Sends notifications on offline, repeats every 10 minutes, notifies immediately on recovery
|
||||
@@ -1256,7 +1297,9 @@ def start_monitoring_worker():
|
||||
trigger=IntervalTrigger(seconds=10),
|
||||
id='vm_cache_sync',
|
||||
name='Sync VM cache from Proxmox',
|
||||
replace_existing=True
|
||||
replace_existing=True,
|
||||
# First run right away, on the scheduler thread — see below.
|
||||
next_run_time=_dt.now(),
|
||||
)
|
||||
|
||||
# Task queue processing - every 5 seconds
|
||||
@@ -1313,12 +1356,10 @@ def start_monitoring_worker():
|
||||
except ImportError as e:
|
||||
logger.warning(f"Proxmox cache cleanup not available: {e}")
|
||||
|
||||
# Run initial VM cache sync on startup
|
||||
try:
|
||||
logger.info("Running initial VM cache sync...")
|
||||
monitoring_worker.sync_vm_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f"Initial VM cache sync failed: {e}")
|
||||
# The initial VM cache sync used to run inline here, i.e. inside the
|
||||
# FastAPI lifespan: with a slow or unreachable Proxmox node the panel did
|
||||
# not accept requests (nor pass its healthcheck) until every server timed
|
||||
# out. It now runs immediately on the scheduler (next_run_time above).
|
||||
|
||||
# Run initial update check on startup (delayed by 60 seconds to let the app fully start)
|
||||
def delayed_update_check():
|
||||
|
||||
@@ -1939,6 +1939,92 @@ def migrate_setting_view_panel_only(conn):
|
||||
logger.info("✓ No roles needed the setting:view split")
|
||||
|
||||
|
||||
def migrate_encrypt_two_factor_secret(conn):
|
||||
"""Migration 44: store users.two_factor_secret encrypted (Fernet).
|
||||
|
||||
A plaintext TOTP seed in a DB dump or backup lets anyone generate valid
|
||||
codes and skip the second factor. The column is widened first — a Fernet
|
||||
token of a 32-char base32 seed is ~140 chars. Idempotent; the encryption
|
||||
step is skipped when FERNET_KEY is not set (the model then stores
|
||||
plaintext, as before).
|
||||
"""
|
||||
import os
|
||||
if not column_exists(conn, 'users', 'two_factor_secret'):
|
||||
return
|
||||
conn.execute(text("ALTER TABLE users ALTER COLUMN two_factor_secret TYPE VARCHAR(255)"))
|
||||
|
||||
fernet_key = os.environ.get("FERNET_KEY")
|
||||
if not fernet_key:
|
||||
return
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
f = Fernet(fernet_key.encode())
|
||||
rows = conn.execute(text(
|
||||
"SELECT id, two_factor_secret FROM users "
|
||||
"WHERE two_factor_secret IS NOT NULL AND two_factor_secret != ''"
|
||||
)).fetchall()
|
||||
count = 0
|
||||
for row in rows:
|
||||
try:
|
||||
f.decrypt(row.two_factor_secret.encode())
|
||||
continue # already encrypted
|
||||
except (InvalidToken, Exception):
|
||||
pass
|
||||
conn.execute(
|
||||
text("UPDATE users SET two_factor_secret = :s WHERE id = :id"),
|
||||
{"s": f.encrypt(row.two_factor_secret.encode()).decode(), "id": row.id},
|
||||
)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(f"Migration 44: Encrypted {count} two-factor secret(s)")
|
||||
|
||||
|
||||
def migrate_hot_path_indexes(conn):
|
||||
"""Migration 45: indexes for per-user task polling.
|
||||
|
||||
/api/all-tasks and the active-task badge poll every few seconds per
|
||||
logged-in user and filter these tables by user_id; without an index each
|
||||
poll scans the whole table.
|
||||
"""
|
||||
for table, name, cols in (
|
||||
("app_operations", "idx_app_operations_user", "user_id, started_at"),
|
||||
("proxmox_tasks", "idx_proxmox_tasks_user_created", "user_id, created_at"),
|
||||
("deploy_tasks", "idx_deploy_tasks_user_created", "user_id, created_at"),
|
||||
("script_executions", "idx_script_executions_user_started", "user_id, started_at"),
|
||||
):
|
||||
if table_exists(conn, table):
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {name} ON {table}({cols})"))
|
||||
|
||||
|
||||
def migrate_encrypt_notification_secrets(conn):
|
||||
"""Migration 46: encrypt the SMTP password and Telegram bot token at rest.
|
||||
|
||||
They sat in panel_settings in plaintext while server passwords and TOTP
|
||||
seeds are Fernet-encrypted. Idempotent; skipped without FERNET_KEY.
|
||||
"""
|
||||
import os
|
||||
fernet_key = os.environ.get("FERNET_KEY")
|
||||
if not fernet_key or not table_exists(conn, 'panel_settings'):
|
||||
return
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
f = Fernet(fernet_key.encode())
|
||||
rows = conn.execute(text(
|
||||
"SELECT id, value FROM panel_settings "
|
||||
"WHERE key IN ('smtp_password', 'telegram_bot_token') AND value IS NOT NULL AND value != ''"
|
||||
)).fetchall()
|
||||
count = 0
|
||||
for row in rows:
|
||||
try:
|
||||
f.decrypt(row.value.encode())
|
||||
continue # already encrypted
|
||||
except (InvalidToken, Exception):
|
||||
pass
|
||||
conn.execute(text("UPDATE panel_settings SET value = :v WHERE id = :id"),
|
||||
{"v": f.encrypt(row.value.encode()).decode(), "id": row.id})
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(f"Migration 46: Encrypted {count} notification secret(s)")
|
||||
|
||||
|
||||
def run_all_migrations(engine, db_session=None):
|
||||
"""
|
||||
Run all migrations in order.
|
||||
@@ -2297,6 +2383,30 @@ def run_all_migrations(engine, db_session=None):
|
||||
logger.warning(f"IPAM multi-IP migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 44: encrypt users.two_factor_secret at rest
|
||||
try:
|
||||
migrate_encrypt_two_factor_secret(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Two-factor secret encryption migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 45: indexes for per-user task polling
|
||||
try:
|
||||
migrate_hot_path_indexes(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Hot-path index migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 46: encrypt SMTP password / Telegram token at rest
|
||||
try:
|
||||
migrate_encrypt_notification_secrets(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Notification secrets encryption migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("✓ All migrations completed successfully")
|
||||
logger.info("=" * 50)
|
||||
|
||||
@@ -3,6 +3,7 @@ testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
addopts =
|
||||
-ra
|
||||
--strict-markers
|
||||
|
||||
@@ -198,6 +198,7 @@ def mock_proxmox():
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.is_connected.return_value = True
|
||||
mock_client_instance.check_connection.return_value = (True, None, None)
|
||||
mock_client_instance.get_next_vmid.return_value = 101
|
||||
mock_client_instance.get_nodes.return_value = [{"node": "pve1", "uptime": 99999}]
|
||||
mock_client_instance.get_all_resources.return_value = {
|
||||
|
||||
@@ -100,10 +100,26 @@ class TestClientIPParsing:
|
||||
client=SimpleNamespace(host=client_host),
|
||||
)
|
||||
|
||||
def test_returns_first_valid_xff(self, auth):
|
||||
req = self._request_with_headers({"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
|
||||
def test_uses_proxy_appended_xff_entry(self, auth):
|
||||
# nginx's $proxy_add_x_forwarded_for appends the real peer; left-most
|
||||
# entries come from the client and must not be trusted.
|
||||
req = self._request_with_headers({"X-Forwarded-For": "1.2.3.4, 203.0.113.5"})
|
||||
assert auth.get_client_ip(req) == "203.0.113.5"
|
||||
|
||||
def test_prefers_x_real_ip(self, auth):
|
||||
req = self._request_with_headers(
|
||||
{"X-Real-IP": "203.0.113.7", "X-Forwarded-For": "1.2.3.4, 203.0.113.7"}
|
||||
)
|
||||
assert auth.get_client_ip(req) == "203.0.113.7"
|
||||
|
||||
def test_ignores_proxy_headers_from_public_peer(self, auth):
|
||||
# A client hitting the API directly cannot choose its own address.
|
||||
req = self._request_with_headers(
|
||||
{"X-Real-IP": "10.9.9.9", "X-Forwarded-For": "10.9.9.9"},
|
||||
client_host="8.8.4.4",
|
||||
)
|
||||
assert auth.get_client_ip(req) == "8.8.4.4"
|
||||
|
||||
def test_rejects_spoofed_xff(self, auth):
|
||||
# Non-IP value must be ignored — fall back to client.host.
|
||||
req = self._request_with_headers(
|
||||
|
||||
36
backend/tests/test_image_upload.py
Normal file
36
backend/tests/test_image_upload.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Image upload hands the whole file to the background worker."""
|
||||
import os
|
||||
import threading
|
||||
|
||||
from app.models import ProxmoxServer
|
||||
|
||||
|
||||
def test_upload_copies_file_for_worker(client, db_session, admin_headers, monkeypatch):
|
||||
from app.api.proxmox import async_ops
|
||||
|
||||
srv = ProxmoxServer(name="pve-up", hostname="pve-up.local", ip_address="10.0.0.9", port=8006,
|
||||
api_user="root@pam", use_password=True, password="x", verify_ssl=False)
|
||||
db_session.add(srv)
|
||||
db_session.commit()
|
||||
|
||||
seen = {}
|
||||
done = threading.Event()
|
||||
|
||||
def fake_worker(task_id, server_id, node, storage, content, name, tmp_path, tmp_dir, *rest):
|
||||
with open(tmp_path, "rb") as f:
|
||||
seen["data"] = f.read()
|
||||
seen["name"] = os.path.basename(tmp_path)
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(async_ops, "_do_image_upload_sync", fake_worker)
|
||||
payload = os.urandom(3 * 1024 * 1024 + 17)
|
||||
|
||||
resp = client.post(
|
||||
f"/proxmox/api/{srv.id}/images/upload", headers=admin_headers,
|
||||
data={"node": "pve1", "storage": "local", "content": "iso"},
|
||||
files={"file": ("debian.iso", payload, "application/octet-stream")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert done.wait(10)
|
||||
assert seen["data"] == payload and seen["name"] == "debian.iso"
|
||||
30
backend/tests/test_interrupted_operations.py
Normal file
30
backend/tests/test_interrupted_operations.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Operations run in threads of the backend process are failed after a restart."""
|
||||
import app.db
|
||||
from app.models import AppOperation, DeployTask, InstalledApp, ScriptExecution, TaskQueue
|
||||
from app.services import task_queue_service
|
||||
|
||||
|
||||
def test_restart_fails_operations_nothing_will_finish(db_session, seed_users, monkeypatch):
|
||||
monkeypatch.setattr(task_queue_service, "SessionLocal", app.db.SessionLocal)
|
||||
uid = seed_users["user"].id
|
||||
db_session.add_all([
|
||||
DeployTask(status="running", name="vm1", template_id=1, server_id=1, user_id=uid),
|
||||
DeployTask(status="pending", name="vm2", template_id=1, server_id=1, user_id=uid),
|
||||
DeployTask(status="completed", name="vm3", template_id=1, server_id=1, user_id=uid),
|
||||
AppOperation(type="install", status="running", user_id=uid),
|
||||
ScriptExecution(server_id=1, target_type="node", status="running", user_id=uid),
|
||||
TaskQueue(task_type="bulk_start", status="running", user_id=uid, total_items=1,
|
||||
completed_items=0, failed_items=0, task_data=[], results=[]),
|
||||
InstalledApp(name="nginx", status="installing", owner_id=uid),
|
||||
InstalledApp(name="ok", status="running", owner_id=uid),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
assert task_queue_service.recover_interrupted_tasks() == 6
|
||||
|
||||
db_session.expire_all()
|
||||
assert sorted(t.status for t in db_session.query(DeployTask)) == ["completed", "failed", "failed"]
|
||||
assert db_session.query(AppOperation).one().status == "failed"
|
||||
assert db_session.query(ScriptExecution).one().status == "failed"
|
||||
assert db_session.query(TaskQueue).one().status == "failed"
|
||||
assert {a.name: a.status for a in db_session.query(InstalledApp)} == {"nginx": "error", "ok": "running"}
|
||||
21
backend/tests/test_log_redaction.py
Normal file
21
backend/tests/test_log_redaction.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Query-string secrets of WebSocket URLs must not reach the logs."""
|
||||
import logging
|
||||
|
||||
from app.main import _RedactSecretsFilter, _redact_secrets
|
||||
|
||||
|
||||
def test_ws_tokens_are_redacted():
|
||||
line = '1.2.3.4:5 - "WebSocket /proxmox/ws/vnc/1/pve/qemu/100?port=5900&vncticket=PVEVNC:abc&auth_ticket=h1&token=eyJ.x.y" [accepted]'
|
||||
|
||||
out = _redact_secrets(line)
|
||||
|
||||
assert "eyJ.x.y" not in out and "PVEVNC:abc" not in out and "h1" not in out
|
||||
assert "port=5900" in out and "token=***" in out
|
||||
|
||||
|
||||
def test_filter_rewrites_uvicorn_records():
|
||||
record = logging.LogRecord("uvicorn.error", logging.INFO, __file__, 1,
|
||||
'%s - "WebSocket %s" [accepted]', ("1.2.3.4", "/ws/tasks?token=secret"), None)
|
||||
|
||||
assert _RedactSecretsFilter().filter(record) is True
|
||||
assert "secret" not in record.getMessage()
|
||||
61
backend/tests/test_node_ssh_credentials.py
Normal file
61
backend/tests/test_node_ssh_credentials.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Root SSH to a node must work for servers that use API-token auth.
|
||||
|
||||
Token auto-provisioning keeps the root password (encrypted) for SSH, but the
|
||||
client built by ``from_server`` used to drop it, so App Store / golden-template
|
||||
SSH failed with "No authentication methods available".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import paramiko
|
||||
|
||||
from app import ssh_client as sc
|
||||
from app.proxmox.client import ProxmoxClient
|
||||
|
||||
|
||||
def _server(**overrides):
|
||||
base = dict(
|
||||
ip_address="10.0.0.3", hostname="pve", port=8006, api_user="root@pam",
|
||||
api_token_name="pvemanager", api_token_value="secret",
|
||||
use_password=False, password="rootpw", verify_ssl=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _build(server):
|
||||
with patch.object(ProxmoxClient, "__init__", lambda self, **kw: setattr(self, "_password", kw.get("password"))):
|
||||
return ProxmoxClient.from_server(server)
|
||||
|
||||
|
||||
def test_token_client_keeps_stored_password_for_ssh():
|
||||
assert _build(_server())._password == "rootpw"
|
||||
|
||||
|
||||
def test_token_client_without_password_has_none():
|
||||
assert _build(_server(password=None))._password is None
|
||||
|
||||
|
||||
def test_password_client_uses_password():
|
||||
assert _build(_server(use_password=True))._password == "rootpw"
|
||||
|
||||
|
||||
def test_no_auth_methods_fails_fast_with_hint(monkeypatch, tmp_path):
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "SSH_KNOWN_HOSTS_FILE", str(tmp_path / "kh"))
|
||||
sc.SSHClient._connections.clear()
|
||||
calls = []
|
||||
|
||||
def fake_connect(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
raise paramiko.SSHException("No authentication methods available")
|
||||
|
||||
monkeypatch.setattr(paramiko.SSHClient, "connect", fake_connect)
|
||||
monkeypatch.setattr(sc.time, "sleep", lambda s: None)
|
||||
|
||||
ssh = sc.SSHClient("10.0.0.3", username="root")
|
||||
assert ssh.connect() is False
|
||||
assert len(calls) == 1 # no pointless retries
|
||||
assert "no credentials" in ssh.last_error
|
||||
26
backend/tests/test_notification_secrets.py
Normal file
26
backend/tests/test_notification_secrets.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""SMTP password and Telegram token are stored encrypted and read back in clear."""
|
||||
from app.models import PanelSettings
|
||||
from app.services import notification_channels
|
||||
|
||||
|
||||
def test_notification_secrets_encrypted_at_rest(client, db_session, admin_headers):
|
||||
resp = client.put("/settings/api/notification-channels/smtp", headers=admin_headers,
|
||||
json={"host": "smtp.example.com", "username": "u", "password": "s3cret-pass"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.put("/settings/api/notification-channels/telegram", headers=admin_headers,
|
||||
json={"telegram_bot_token": "123456:ABCDEF"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
db_session.expire_all()
|
||||
raw = {s.key: s.value for s in db_session.query(PanelSettings).filter(
|
||||
PanelSettings.key.in_(["smtp_password", "telegram_bot_token"]))}
|
||||
assert raw["smtp_password"] != "s3cret-pass" and raw["telegram_bot_token"] != "123456:ABCDEF"
|
||||
|
||||
assert notification_channels.get_notification_setting("smtp_password") == "s3cret-pass"
|
||||
assert notification_channels.get_notification_setting("telegram_bot_token") == "123456:ABCDEF"
|
||||
|
||||
# Rows written before the change (plaintext) still read back.
|
||||
row = db_session.query(PanelSettings).filter(PanelSettings.key == "smtp_password").one()
|
||||
row.value = "legacy-plain"
|
||||
db_session.commit()
|
||||
assert notification_channels.get_notification_setting("smtp_password") == "legacy-plain"
|
||||
32
backend/tests/test_quota_in_flight.py
Normal file
32
backend/tests/test_quota_in_flight.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Queued creations count against max_instances (parallel deploys bypassed it)."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import DeployTask, UserQuota, VMInstance
|
||||
from app.services.quota_service import check_quota
|
||||
|
||||
|
||||
def test_pending_deploy_counts_toward_instance_quota(db_session, seed_users):
|
||||
uid = seed_users["user"].id
|
||||
db_session.add(UserQuota(user_id=uid, max_instances=1))
|
||||
db_session.add(DeployTask(status="pending", kind="deploy", name="first", user_id=uid))
|
||||
db_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_quota(db_session, uid)
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
|
||||
def test_cached_or_finished_tasks_are_not_double_counted(db_session, seed_users):
|
||||
uid = seed_users["user"].id
|
||||
db_session.add(UserQuota(user_id=uid, max_instances=2))
|
||||
db_session.add(VMInstance(server_id=1, vmid=150, node="pve1", vm_type="qemu", name="a",
|
||||
owner_id=uid, status="running"))
|
||||
db_session.add_all([
|
||||
DeployTask(status="running", kind="deploy", name="a", user_id=uid, vmid=150), # already cached
|
||||
DeployTask(status="failed", kind="deploy", name="b", user_id=uid),
|
||||
DeployTask(status="pending", kind="migrate", name="c", user_id=uid),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
check_quota(db_session, uid) # 1 instance + 0 in flight + 1 new <= 2
|
||||
845
backend/tests/test_security_regressions.py
Normal file
845
backend/tests/test_security_regressions.py
Normal file
@@ -0,0 +1,845 @@
|
||||
"""Regression tests for access-control holes closed in the security audit.
|
||||
|
||||
Each test pins one concrete attack that used to work:
|
||||
|
||||
* a tenant with ``vm:console`` running commands in somebody else's guest;
|
||||
* ``user:create`` / ``user:update`` holders minting or taking over admins;
|
||||
* WebSocket metrics channels and RRD requests for guests the user does not own;
|
||||
* the impersonation token being redeemable after its session was terminated;
|
||||
* WebSocket tickets being replayable, outliving their TTL or their session,
|
||||
and the session JWT being accepted in WebSocket URLs;
|
||||
* password-reset links following the attacker-supplied ``Origin`` header;
|
||||
* script parameters / git sources smuggling shell or git options.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import ProxmoxServer, Role, User, VMInstance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(db_session):
|
||||
srv = ProxmoxServer(
|
||||
name="pve-sec", hostname="pve-sec.local", ip_address="10.0.0.5", port=8006,
|
||||
api_user="root@pam", use_password=True, password="secret", verify_ssl=False,
|
||||
is_online=True,
|
||||
)
|
||||
db_session.add(srv)
|
||||
db_session.commit()
|
||||
db_session.refresh(srv)
|
||||
return srv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guests(db_session, server, seed_users):
|
||||
own = VMInstance(server_id=server.id, vmid=100, node="pve1", vm_type="qemu",
|
||||
name="mine", owner_id=seed_users["user"].id, status="running")
|
||||
foreign = VMInstance(server_id=server.id, vmid=101, node="pve1", vm_type="qemu",
|
||||
name="theirs", owner_id=seed_users["admin"].id, status="running")
|
||||
db_session.add_all([own, foreign])
|
||||
db_session.commit()
|
||||
return {"own": own, "foreign": foreign}
|
||||
|
||||
|
||||
def _grant(db_session, user: User, **perms: bool) -> None:
|
||||
role = user.role
|
||||
role.permissions = {**(role.permissions or {}), **{k.replace("__", ":"): v for k, v in perms.items()}}
|
||||
db_session.commit()
|
||||
|
||||
|
||||
# ── Guest command execution (IDOR) ─────────────────────────────────────────
|
||||
|
||||
def test_console_user_cannot_execute_in_foreign_vm(client, db_session, seed_users, user_headers,
|
||||
server, guests, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], vm__console=True)
|
||||
mock_proxmox["client"].execute_command.return_value = {"success": True}
|
||||
|
||||
resp = client.post(
|
||||
f"/proxmox/api/{server.id}/vm/101/execute",
|
||||
params={"node": "pve1", "command": "id"}, headers=user_headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
mock_proxmox["client"].execute_command.assert_not_called()
|
||||
|
||||
|
||||
def test_console_user_can_execute_in_own_vm(client, db_session, seed_users, user_headers,
|
||||
server, guests, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], vm__console=True)
|
||||
mock_proxmox["client"].execute_command.return_value = {"success": True}
|
||||
|
||||
resp = client.post(
|
||||
f"/proxmox/api/{server.id}/vm/100/execute",
|
||||
params={"node": "pve1", "command": "id"}, headers=user_headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_view_user_cannot_read_foreign_vm_config(client, user_headers, server, guests, mock_proxmox):
|
||||
resp = client.get(f"/proxmox/api/{server.id}/vm/101/config", params={"node": "pve1"},
|
||||
headers=user_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_clone_respects_quota(client, db_session, seed_users, user_headers, server, guests, mock_proxmox):
|
||||
from app.models import UserQuota
|
||||
_grant(db_session, seed_users["user"], vm__create=True)
|
||||
db_session.add(UserQuota(user_id=seed_users["user"].id, max_instances=1))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post(f"/proxmox/api/{server.id}/vm/100/clone", params={"node": "pve1"},
|
||||
headers=user_headers, json={"new_name": "copy"})
|
||||
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
def test_tenant_cannot_reach_unassigned_server(client, db_session, seed_users, user_headers,
|
||||
server, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], node__view=True)
|
||||
mock_proxmox["client"].get_node_syslog.return_value = []
|
||||
url = f"/proxmox/api/servers/{server.id}/nodes/pve1/syslog"
|
||||
|
||||
assert client.get(url, headers=user_headers).status_code == 404
|
||||
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
assert client.get(url, headers=user_headers).status_code != 404
|
||||
|
||||
|
||||
def test_owner_keeps_access_to_guest_on_unassigned_server(client, user_headers, server, guests,
|
||||
mock_proxmox):
|
||||
mock_proxmox["client"].get_snapshots.return_value = []
|
||||
resp = client.get(f"/proxmox/api/{server.id}/vm/100/snapshots", params={"node": "pve1"},
|
||||
headers=user_headers)
|
||||
assert resp.status_code != 404
|
||||
|
||||
|
||||
# ── User management escalation ─────────────────────────────────────────────
|
||||
|
||||
def test_user_create_permission_cannot_mint_admin(client, db_session, seed_users, user_headers):
|
||||
_grant(db_session, seed_users["user"], user__create=True)
|
||||
|
||||
resp = client.post("/admin/api/users", headers=user_headers, json={
|
||||
"username": "evil", "email": "evil@example.com", "password": "Passw0rdX",
|
||||
"is_admin": True,
|
||||
})
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert db_session.query(User).filter(User.username == "evil").first() is None
|
||||
|
||||
|
||||
def test_user_create_permission_cannot_assign_admin_role(client, db_session, seed_users, user_headers):
|
||||
_grant(db_session, seed_users["user"], user__create=True)
|
||||
admin_role = db_session.query(Role).filter(Role.name == "admin").one()
|
||||
|
||||
resp = client.post("/admin/api/users", headers=user_headers, json={
|
||||
"username": "evil2", "email": "evil2@example.com", "password": "Passw0rdX",
|
||||
"role_id": admin_role.id,
|
||||
})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_user_update_permission_cannot_take_over_role_admin(client, db_session, seed_users, user_headers):
|
||||
"""An account that is admin through its role (is_admin=False) is still protected."""
|
||||
from app.auth import get_password_hash
|
||||
_grant(db_session, seed_users["user"], user__update=True)
|
||||
admin_role = db_session.query(Role).filter(Role.name == "admin").one()
|
||||
role_admin = User(username="roleadmin", email="ra@example.com", is_active=True, is_admin=False,
|
||||
hashed_password=get_password_hash("Orig1nalPass"), role=admin_role)
|
||||
db_session.add(role_admin)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post(f"/admin/api/users/{role_admin.id}/reset-password", headers=user_headers,
|
||||
json={"new_password": "Hacked123Pass"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = client.put(f"/admin/api/users/{role_admin.id}", headers=user_headers,
|
||||
json={"password": "Hacked123Pass"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_user_update_permission_cannot_self_assign_admin_role(client, db_session, seed_users, user_headers):
|
||||
_grant(db_session, seed_users["user"], user__update=True)
|
||||
admin_role = db_session.query(Role).filter(Role.name == "admin").one()
|
||||
|
||||
resp = client.put(f"/admin/api/users/{seed_users['user'].id}", headers=user_headers,
|
||||
json={"role_id": admin_role.id})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ── WebSocket channel / RRD authorization ──────────────────────────────────
|
||||
|
||||
def test_ws_instance_channel_requires_ownership(db_session, server, guests, seed_users):
|
||||
from app.db import SessionLocal
|
||||
from app.services.ws_access import authorize_channel
|
||||
|
||||
uid = seed_users["user"].id
|
||||
own_ok, _ = authorize_channel(SessionLocal, uid, f"instance_metrics:{server.id}_100_qemu_pve1")
|
||||
foreign_ok, _ = authorize_channel(SessionLocal, uid, f"instance_metrics:{server.id}_101_qemu_pve1")
|
||||
junk_ok, _ = authorize_channel(SessionLocal, uid, "anything-else")
|
||||
|
||||
assert own_ok is True
|
||||
assert foreign_ok is False
|
||||
assert junk_ok is False
|
||||
|
||||
|
||||
def test_ws_instances_list_is_filtered_for_tenants(db_session, server, guests, seed_users):
|
||||
from app.db import SessionLocal
|
||||
from app.services.ws_access import authorize_channel
|
||||
|
||||
ok, payload_filter = authorize_channel(SessionLocal, seed_users["user"].id, "instances_list_metrics")
|
||||
assert ok and payload_filter is not None
|
||||
|
||||
payload = {"type": "metrics_update", "data": {"items": [
|
||||
{"server_id": server.id, "vmid": 100}, {"server_id": server.id, "vmid": 101},
|
||||
]}}
|
||||
assert [i["vmid"] for i in payload_filter(payload)["data"]["items"]] == [100]
|
||||
|
||||
|
||||
def test_ws_rrd_request_requires_ownership(db_session, server, guests, seed_users):
|
||||
from app.db import SessionLocal
|
||||
from app.services.ws_access import authorize_rrd_request
|
||||
|
||||
uid = seed_users["user"].id
|
||||
assert authorize_rrd_request(SessionLocal, uid, "get_rrd", {"server_id": server.id, "vmid": 100})
|
||||
assert not authorize_rrd_request(SessionLocal, uid, "get_rrd", {"server_id": server.id, "vmid": 101})
|
||||
|
||||
|
||||
def test_ws_manager_applies_subscriber_filter():
|
||||
import asyncio
|
||||
import json
|
||||
from app.websocket_manager import ConnectionManager
|
||||
|
||||
class _WS:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send_text(self, text):
|
||||
self.sent.append(json.loads(text))
|
||||
|
||||
mgr = ConnectionManager()
|
||||
plain, filtered = _WS(), _WS()
|
||||
mgr.subscribe(plain, "ch")
|
||||
mgr.subscribe(filtered, "ch", lambda p: {**p, "items": p["items"][:1]})
|
||||
|
||||
asyncio.run(mgr.broadcast_to_channel("ch", {"items": [1, 2]}))
|
||||
|
||||
assert plain.sent == [{"items": [1, 2]}]
|
||||
assert filtered.sent == [{"items": [1]}]
|
||||
|
||||
|
||||
# ── Sessions ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_stop_impersonate_rejects_terminated_session(client, db_session, seed_users):
|
||||
from app.auth import create_access_token
|
||||
from app.services.security_service import SecurityService
|
||||
|
||||
session, _ = SecurityService.create_session(db_session, seed_users["user"], "127.0.0.1", "t")
|
||||
token = create_access_token(
|
||||
data={"sub": seed_users["user"].username, "impersonator": seed_users["admin"].username},
|
||||
session_token=session.session_token,
|
||||
)
|
||||
SecurityService.terminate_session(db_session, session.session_token)
|
||||
|
||||
resp = client.post("/api/auth/stop-impersonate", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_logout_session_cannot_open_task_websocket(client, db_session, seed_users):
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from app.auth import create_access_token
|
||||
from app.services.security_service import SecurityService
|
||||
|
||||
session, _ = SecurityService.create_session(db_session, seed_users["user"], "127.0.0.1", "t")
|
||||
token = create_access_token(data={"sub": seed_users["user"].username},
|
||||
session_token=session.session_token)
|
||||
SecurityService.terminate_session(db_session, session.session_token)
|
||||
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect(f"/ws/tasks?token={token}") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
# ── WebSocket tickets ──────────────────────────────────────────────────────
|
||||
|
||||
def _ws_ticket(client, headers) -> str:
|
||||
resp = client.post("/api/auth/ws-ticket", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["ticket"]
|
||||
|
||||
|
||||
def test_ws_ticket_requires_authentication(client):
|
||||
assert client.post("/api/auth/ws-ticket").status_code in (401, 403)
|
||||
|
||||
|
||||
def test_ws_ticket_is_single_use(client, db_session, seed_users, user_headers):
|
||||
from app.auth import authenticate_ws
|
||||
|
||||
ticket = _ws_ticket(client, user_headers)
|
||||
assert user_headers["Authorization"].split()[1] != ticket # opaque, not the JWT
|
||||
|
||||
user = authenticate_ws(ticket, db_session)
|
||||
assert user is not None and user.id == seed_users["user"].id
|
||||
assert authenticate_ws(ticket, db_session) is None
|
||||
|
||||
|
||||
def test_ws_ticket_opens_task_websocket_once(client, user_headers):
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
ticket = _ws_ticket(client, user_headers)
|
||||
with client.websocket_connect(f"/ws/tasks?ticket={ticket}"):
|
||||
pass
|
||||
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect(f"/ws/tasks?ticket={ticket}") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_ws_ticket_expires(client, db_session, user_headers):
|
||||
import app.auth as auth_mod
|
||||
|
||||
ticket = _ws_ticket(client, user_headers)
|
||||
user_id, session_token, _ = auth_mod._ws_tickets[ticket]
|
||||
auth_mod._ws_tickets[ticket] = (user_id, session_token, auth_mod.time.monotonic() - 1)
|
||||
|
||||
assert auth_mod.authenticate_ws(ticket, db_session) is None
|
||||
|
||||
|
||||
def test_ws_ticket_is_bound_to_its_user(client, db_session, seed_users, user_headers, admin_headers):
|
||||
from app.auth import authenticate_ws
|
||||
|
||||
user_ticket = _ws_ticket(client, user_headers)
|
||||
admin_ticket = _ws_ticket(client, admin_headers)
|
||||
|
||||
assert authenticate_ws(user_ticket, db_session).id == seed_users["user"].id
|
||||
assert authenticate_ws(admin_ticket, db_session).id == seed_users["admin"].id
|
||||
assert authenticate_ws("forged-ticket", db_session) is None
|
||||
|
||||
|
||||
def test_ws_ticket_of_logged_out_session_is_refused(client, db_session, seed_users):
|
||||
from app.auth import authenticate_ws
|
||||
from tests.conftest import _get_headers_for_user
|
||||
|
||||
headers = _get_headers_for_user(db_session, seed_users["user"])
|
||||
ticket = _ws_ticket(client, headers)
|
||||
assert client.post("/api/auth/logout", headers=headers).status_code == 200
|
||||
|
||||
assert authenticate_ws(ticket, db_session) is None
|
||||
|
||||
|
||||
def test_task_websocket_refuses_jwt_in_query(client, user_headers):
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# The session JWT in ?token= leaked into logs and browser history; a
|
||||
# valid one must not open the socket any more.
|
||||
jwt_token = user_headers["Authorization"].split()[1]
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect(f"/ws/tasks?token={jwt_token}") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_profile_password_change_enforces_policy_and_ends_other_sessions(client, db_session, seed_users):
|
||||
from app.models import ActiveSession
|
||||
from tests.conftest import _get_headers_for_user
|
||||
|
||||
current = _get_headers_for_user(db_session, seed_users["user"])
|
||||
other = _get_headers_for_user(db_session, seed_users["user"])
|
||||
|
||||
weak = client.post("/settings/api/change-password", headers=current, json={
|
||||
"current_password": "UserPass123!", "new_password": "weakpw", "confirm_password": "weakpw"})
|
||||
assert weak.status_code == 400
|
||||
|
||||
ok = client.post("/settings/api/change-password", headers=current, json={
|
||||
"current_password": "UserPass123!", "new_password": "Str0ngerPass", "confirm_password": "Str0ngerPass"})
|
||||
assert ok.status_code == 200
|
||||
|
||||
assert client.get("/api/auth/me", headers=current).status_code == 200
|
||||
assert client.get("/api/auth/me", headers=other).status_code == 401
|
||||
|
||||
|
||||
def test_password_reset_link_ignores_origin_header(client, db_session, seed_users, monkeypatch):
|
||||
from app.api import auth as auth_api
|
||||
from app.config import settings
|
||||
|
||||
sent = {}
|
||||
|
||||
async def _capture(to_email, display_name, lang, link, expire_minutes):
|
||||
sent["link"] = link
|
||||
|
||||
monkeypatch.setattr(auth_api, "_send_reset_email", _capture)
|
||||
monkeypatch.setattr(settings, "PANEL_URL", "https://panel.example.com")
|
||||
|
||||
resp = client.post("/api/auth/forgot-password", json={"email": "testuser@example.com"},
|
||||
headers={"Origin": "https://evil.example"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert sent["link"].startswith("https://panel.example.com/reset-password?token=")
|
||||
|
||||
|
||||
# ── Script catalog input hardening ─────────────────────────────────────────
|
||||
|
||||
def test_render_params_drops_injected_names():
|
||||
from app.services.script_engine import render_params
|
||||
|
||||
rendered = render_params("echo ok", {"GOOD": "a'b", "X;touch /tmp/pwn;Y": "1"})
|
||||
|
||||
assert "export GOOD='a'\\''b'" in rendered
|
||||
assert "touch" not in rendered
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["--upload-pack=touch /tmp/pwn", "ext::sh -c id", "file:///etc"])
|
||||
def test_repo_url_rejects_option_and_local_transports(url):
|
||||
from app.services.script_catalog_sync import validate_repo_url
|
||||
with pytest.raises(ValueError):
|
||||
validate_repo_url(url)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("glob", ["/etc/*", "../../*", "a/../../b"])
|
||||
def test_repo_glob_must_stay_inside_clone(glob):
|
||||
from app.services.script_catalog_sync import validate_path_glob
|
||||
with pytest.raises(ValueError):
|
||||
validate_path_glob(glob)
|
||||
|
||||
|
||||
def test_settings_cache_is_per_key(db_session, monkeypatch):
|
||||
"""A miss on one key must not keep every other cached value alive forever."""
|
||||
from datetime import timedelta
|
||||
from app.services import security_service as ss
|
||||
|
||||
SecurityService = ss.SecurityService
|
||||
SecurityService._settings_cache.clear()
|
||||
SecurityService.update_setting(db_session, "session_timeout_minutes", "15")
|
||||
assert SecurityService.get_setting(db_session, "session_timeout_minutes") == "15"
|
||||
|
||||
# Change the row behind the cache's back, then age only that key out.
|
||||
from app.models import SecuritySetting
|
||||
db_session.query(SecuritySetting).filter(SecuritySetting.key == "session_timeout_minutes") \
|
||||
.update({"value": "30"})
|
||||
db_session.commit()
|
||||
value, at = SecurityService._settings_cache["session_timeout_minutes"]
|
||||
SecurityService._settings_cache["session_timeout_minutes"] = (value, at - timedelta(seconds=120))
|
||||
SecurityService.get_setting(db_session, "some_other_key", "x") # unrelated miss
|
||||
|
||||
assert SecurityService.get_setting(db_session, "session_timeout_minutes") == "30"
|
||||
|
||||
|
||||
def test_generated_secret_key_is_persisted(tmp_path, monkeypatch):
|
||||
"""A placeholder SECRET_KEY is swapped for a stable generated one."""
|
||||
from app import main
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "APPSTORE_DATA_DIR", str(tmp_path / "appstore"))
|
||||
first = main._load_or_create_secret_key()
|
||||
second = main._load_or_create_secret_key()
|
||||
|
||||
assert len(first) == 64
|
||||
assert first == second
|
||||
assert first not in main._INSECURE_SECRET_KEYS
|
||||
|
||||
|
||||
def test_totp_code_cannot_be_replayed():
|
||||
import pyotp
|
||||
from app.services import two_factor_service as tfa
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
code = pyotp.TOTP(secret).now()
|
||||
|
||||
assert tfa.verify_totp(secret, code, replay_key="replay-test") is True
|
||||
assert tfa.verify_totp(secret, code, replay_key="replay-test") is False
|
||||
# Without a replay key (enrollment confirmation) behaviour is unchanged.
|
||||
assert tfa.verify_totp(secret, code) is True
|
||||
|
||||
|
||||
def test_two_factor_secret_encrypted_and_login_code_single_use(client, db_session, seed_users, user_headers):
|
||||
import pyotp
|
||||
from sqlalchemy import text
|
||||
|
||||
setup = client.post("/api/auth/2fa/setup", headers=user_headers)
|
||||
assert setup.status_code == 200
|
||||
secret = setup.json()["secret"]
|
||||
|
||||
raw = db_session.execute(text("SELECT two_factor_secret FROM users WHERE username = 'testuser'")).scalar()
|
||||
assert raw and raw != secret # stored as a Fernet token, not the seed
|
||||
|
||||
totp = pyotp.TOTP(secret)
|
||||
enable = client.post("/api/auth/2fa/enable", headers=user_headers, json={"code": totp.now()})
|
||||
assert enable.status_code == 200
|
||||
|
||||
code = totp.now()
|
||||
login = {"username": "testuser", "password": "UserPass123!", "code": code}
|
||||
first = client.post("/api/auth/login", json=login)
|
||||
assert first.status_code == 200 and first.json().get("access_token")
|
||||
|
||||
replay = client.post("/api/auth/login", json=login)
|
||||
assert replay.status_code == 400
|
||||
|
||||
|
||||
def test_wrong_two_factor_codes_lock_the_account(client, db_session, seed_users, user_headers):
|
||||
import pyotp
|
||||
|
||||
secret = client.post("/api/auth/2fa/setup", headers=user_headers).json()["secret"]
|
||||
client.post("/api/auth/2fa/enable", headers=user_headers, json={"code": pyotp.TOTP(secret).now()})
|
||||
|
||||
for _ in range(5):
|
||||
client.post("/api/auth/login", json={"username": "testuser", "password": "UserPass123!",
|
||||
"code": "000000"})
|
||||
|
||||
db_session.expire_all()
|
||||
user = db_session.query(User).filter(User.username == "testuser").one()
|
||||
assert user.locked_until is not None
|
||||
|
||||
|
||||
# ── Notifications ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_vm_notifications_reach_only_owner_and_admins(db_session, server, guests, seed_users):
|
||||
from app.auth import get_password_hash
|
||||
from app.workers.monitoring_worker import MonitoringWorker
|
||||
|
||||
db_session.add(User(username="bystander", email="b@example.com", is_active=True,
|
||||
hashed_password=get_password_hash("Bystander1"),
|
||||
role=seed_users["user"].role))
|
||||
db_session.commit()
|
||||
users = db_session.query(User).filter(User.is_active == True).all() # noqa: E712
|
||||
|
||||
recipients = MonitoringWorker._vm_recipients(db_session, users, server, 100)
|
||||
|
||||
assert {u.username for u in recipients} == {"testadmin", "testuser"}
|
||||
|
||||
|
||||
# ── Console tickets ────────────────────────────────────────────────────────
|
||||
|
||||
def test_vnc_endpoint_does_not_hand_out_pve_ticket(client, db_session, seed_users, user_headers,
|
||||
server, guests, monkeypatch):
|
||||
import requests
|
||||
from unittest.mock import MagicMock
|
||||
from app.api.proxmox._helpers import claim_pve_ticket
|
||||
|
||||
_grant(db_session, seed_users["user"], vm__console=True)
|
||||
root_ticket = "PVE:root@pam:ROOTTICKET"
|
||||
calls = []
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls.append(url)
|
||||
resp = MagicMock(status_code=200)
|
||||
if url.endswith("/access/ticket"):
|
||||
resp.json.return_value = {"data": {"ticket": root_ticket, "CSRFPreventionToken": "c"}}
|
||||
else:
|
||||
resp.json.return_value = {"data": {"port": 5900, "ticket": "vnc", "password": "p"}}
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(requests, "post", fake_post)
|
||||
resp = client.get(f"/proxmox/api/{server.id}/vm/100/vnc", params={"node": "pve1"},
|
||||
headers=user_headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert root_ticket not in resp.text and "host" not in body
|
||||
handle = body["auth_ticket"]
|
||||
assert claim_pve_ticket(handle, seed_users["admin"].id, server.id, 100) is None
|
||||
|
||||
resp = client.get(f"/proxmox/api/{server.id}/vm/100/vnc", params={"node": "pve1"},
|
||||
headers=user_headers)
|
||||
handle = resp.json()["auth_ticket"]
|
||||
uid = seed_users["user"].id
|
||||
assert claim_pve_ticket(handle, uid, server.id, 100) == root_ticket
|
||||
assert claim_pve_ticket(handle, uid, server.id, 100) is None # single use
|
||||
assert all("10.0.0.5:8006/" in url for url in calls)
|
||||
|
||||
|
||||
def test_pve_authority_uses_server_port():
|
||||
from types import SimpleNamespace
|
||||
from app.api.proxmox._helpers import pve_authority
|
||||
|
||||
assert pve_authority(SimpleNamespace(ip_address="10.0.0.5", port=8443)) == "10.0.0.5:8443"
|
||||
assert pve_authority(SimpleNamespace(ip_address="fd00::5", port=None)) == "[fd00::5]:8006"
|
||||
|
||||
|
||||
# ── Templates / App Store ──────────────────────────────────────────────────
|
||||
|
||||
def test_deploy_and_template_list_respect_server_assignment(client, db_session, seed_users,
|
||||
user_headers, server):
|
||||
from app.models import OSTemplate
|
||||
_grant(db_session, seed_users["user"], vm__create=True, template__view=True)
|
||||
tpl = OSTemplate(group_id=1, server_id=server.id, name="debian", vmid=9000, node="pve1")
|
||||
db_session.add(tpl)
|
||||
db_session.commit()
|
||||
|
||||
listed = client.get("/templates/api/templates", headers=user_headers)
|
||||
assert listed.status_code == 200 and listed.json() == []
|
||||
resp = client.post("/templates/api/deploy", headers=user_headers,
|
||||
json={"template_id": tpl.id, "name": "vm1"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
assert [t["id"] for t in client.get("/templates/api/templates", headers=user_headers).json()] == [tpl.id]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["template_storage", "rootfs_storage", "bridge"])
|
||||
def test_golden_template_rejects_shell_in_names(client, db_session, seed_users, user_headers,
|
||||
server, field):
|
||||
_grant(db_session, seed_users["user"], app__manage=True, app__view=True)
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post("/api/appstore/golden-template", headers=user_headers,
|
||||
json={"server_id": server.id, field: "local; touch /tmp/pwn"})
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_golden_spec_validates_names_itself():
|
||||
from app.appstore.golden_template import GoldenSpec, build_golden_template
|
||||
from app.appstore.pipeline import PocError
|
||||
|
||||
with pytest.raises(PocError):
|
||||
build_golden_template(None, GoldenSpec(server_id=1, bridge="vmbr0 $(id)"))
|
||||
|
||||
|
||||
def test_catalog_logo_is_served_sandboxed(client, db_session, tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
from app.models import CatalogApp
|
||||
|
||||
monkeypatch.setattr(settings, "APPSTORE_DATA_DIR", str(tmp_path))
|
||||
(tmp_path / "logos").mkdir()
|
||||
(tmp_path / "logos" / "evil.svg").write_text("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>")
|
||||
db_session.add(CatalogApp(app_id="evil", name="Evil", logo_path="logos/evil.svg"))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/appstore/catalog/evil/logo")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "sandbox" in resp.headers["content-security-policy"]
|
||||
assert resp.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
# ── Create requests that reference volumes ─────────────────────────────────
|
||||
|
||||
def _lxc_body(server, **over):
|
||||
return {"server_id": server.id, "node": "pve1", "name": "ct1",
|
||||
"ostemplate": "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst", **over}
|
||||
|
||||
|
||||
def test_lxc_create_checks_server_template_and_privileges(client, db_session, seed_users,
|
||||
user_headers, server, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], vm__create=True)
|
||||
url = "/proxmox/api/lxc/deploy"
|
||||
|
||||
assert client.post(url, json=_lxc_body(server), headers=user_headers).status_code == 404
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
|
||||
backup = "local:backup/vzdump-lxc-101-2024_01_01-00_00_00.tar.zst"
|
||||
assert client.post(url, json=_lxc_body(server, ostemplate=backup),
|
||||
headers=user_headers).status_code == 400
|
||||
assert client.post(url, json=_lxc_body(server, unprivileged=False),
|
||||
headers=user_headers).status_code == 403
|
||||
|
||||
|
||||
def test_iso_vm_rejects_non_iso_volumes(client, db_session, seed_users, user_headers, server):
|
||||
_grant(db_session, seed_users["user"], vm__create=True)
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
body = {"server_id": server.id, "node": "pve1", "name": "vm1", "iso": "local-lvm:vm-101-disk-0"}
|
||||
|
||||
resp = client.post("/proxmox/api/vm/create-from-iso", json=body, headers=user_headers)
|
||||
|
||||
assert resp.status_code == 400
|
||||
body.update(iso="local:iso/debian.iso", extra_iso="local-lvm:vm-101-disk-0")
|
||||
assert client.post("/proxmox/api/vm/create-from-iso", json=body,
|
||||
headers=user_headers).status_code == 400
|
||||
|
||||
|
||||
def test_refresh_keeps_impersonation_link(client, seed_users, admin_headers):
|
||||
imp = client.post(f"/api/auth/impersonate/{seed_users['user'].id}", headers=admin_headers)
|
||||
assert imp.status_code == 200, imp.text
|
||||
h = {"Authorization": f"Bearer {imp.json()['access_token']}"}
|
||||
|
||||
refreshed = client.post("/api/auth/refresh", headers=h)
|
||||
assert refreshed.status_code == 200
|
||||
h = {"Authorization": f"Bearer {refreshed.json()['access_token']}"}
|
||||
|
||||
back = client.post("/api/auth/stop-impersonate", headers=h)
|
||||
assert back.status_code == 200, back.text
|
||||
me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {back.json()['access_token']}"})
|
||||
assert me.json()["username"] == "testadmin"
|
||||
|
||||
|
||||
# ── Identifiers that become Proxmox API path segments ──────────────────────
|
||||
|
||||
def test_node_and_path_params_must_be_plain_names(client, db_session, seed_users, user_headers,
|
||||
server, guests, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], vm__start=True)
|
||||
url = f"/proxmox/api/{server.id}/vm/100/start"
|
||||
|
||||
for node in ("pve1/../x", "pve1#", "pve1?a=b"):
|
||||
assert client.post(url, params={"node": node}, headers=user_headers).status_code == 400
|
||||
mock_proxmox["client"].start_vm.assert_not_called()
|
||||
|
||||
# An encoded slash either fails routing (404) or the segment check (400).
|
||||
resp = client.delete(f"/proxmox/api/{server.id}/vm/100/snapshots/a%2Fb",
|
||||
params={"node": "pve1"}, headers=user_headers)
|
||||
assert resp.status_code in (400, 404)
|
||||
mock_proxmox["client"].delete_vm_snapshot.assert_not_called()
|
||||
|
||||
from types import SimpleNamespace
|
||||
from app.api.proxmox._helpers import _identifier_error
|
||||
conn = SimpleNamespace(path_params={"snapname": "a/../b"}, query_params={})
|
||||
assert _identifier_error(conn) == "snapname"
|
||||
|
||||
|
||||
def test_body_node_names_are_validated(client, user_headers, server, guests):
|
||||
items = [{"server_id": server.id, "vmid": 100, "vm_type": "qemu", "name": "a",
|
||||
"node": "pve1/../x"}]
|
||||
resp = client.post("/proxmox/api/bulk-operation", headers=user_headers,
|
||||
json={"action": "start", "items": items})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_backup_delete_rejects_unsafe_volid(client, db_session, seed_users, admin_headers,
|
||||
server, mock_proxmox):
|
||||
resp = client.request(
|
||||
"DELETE", f"/proxmox/api/backups/{server.id}/backup", headers=admin_headers,
|
||||
json={"node": "pve1", "storage": "local", "volid": "local:backup/../../x"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
mock_proxmox["client"].delete_backup.assert_not_called()
|
||||
|
||||
|
||||
def test_upid_format_check_accepts_real_upids():
|
||||
from app.api.proxmox._helpers import _UPID_RE
|
||||
|
||||
assert _UPID_RE.match("UPID:pve1:0000ABCD:0012ABCD:65A1B2C3:qmstart:100:root@pam:")
|
||||
assert _UPID_RE.match("UPID:pve-2:00001234:00001234:65A1B2C3:vzdump::root@pam:")
|
||||
assert not _UPID_RE.match("UPID:pve1:0000ABCD:0012ABCD:65A1B2C3:qmstart:100/../x:root@pam:")
|
||||
|
||||
|
||||
# ── Bulk operations ────────────────────────────────────────────────────────
|
||||
|
||||
def _bulk(client, headers, server, action, *vmids):
|
||||
items = [{"server_id": server.id, "vmid": v, "vm_type": "qemu", "name": str(v),
|
||||
"node": "pve1"} for v in vmids]
|
||||
return client.post("/proxmox/api/bulk-operation", headers=headers,
|
||||
json={"action": action, "items": items})
|
||||
|
||||
|
||||
def test_bulk_operation_rejects_foreign_guest(client, db_session, seed_users, user_headers,
|
||||
server, guests):
|
||||
from app.models import TaskQueue
|
||||
|
||||
resp = _bulk(client, user_headers, server, "stop", 100, 101)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert db_session.query(TaskQueue).count() == 0
|
||||
|
||||
|
||||
def test_bulk_operation_allows_own_guests_with_action_permission(client, db_session, seed_users,
|
||||
user_headers, server, guests):
|
||||
# The stock user role has vm:start but not vm:manage: bulk used to be
|
||||
# admin-only while single start/stop worked for owners.
|
||||
_grant(db_session, seed_users["user"], vm__start=True)
|
||||
assert _bulk(client, user_headers, server, "start", 100).status_code == 200
|
||||
assert _bulk(client, user_headers, server, "delete", 100).status_code == 403
|
||||
|
||||
|
||||
def test_wildcard_role_counts_as_global_view(db_session, seed_users):
|
||||
from app.api.proxmox._helpers import can_view_all_instances
|
||||
|
||||
user = seed_users["user"]
|
||||
assert not can_view_all_instances(user)
|
||||
_grant(db_session, user, **{"vm__*": True})
|
||||
assert can_view_all_instances(user)
|
||||
|
||||
|
||||
# ── Backups ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("volid,vmid", [
|
||||
("local:backup/vzdump-qemu-101-2024_01_01-00_00_00.vma.zst", 101),
|
||||
("nfs:backup/vzdump-lxc-205-2024_01_01-00_00_00.tar.zst", 205),
|
||||
("pbs:backup/vm/300/2024-01-01T00:00:00Z", 300),
|
||||
("local:iso/debian.iso", None),
|
||||
])
|
||||
def test_archive_vmid(volid, vmid):
|
||||
from app.api.proxmox.backup import archive_vmid
|
||||
assert archive_vmid(volid) == vmid
|
||||
|
||||
|
||||
def test_restore_of_foreign_archive_into_own_guest_is_refused(client, db_session, seed_users,
|
||||
user_headers, server, guests,
|
||||
mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], backup__restore=True)
|
||||
body = {"server_id": server.id, "node": "pve1", "vmid": 100,
|
||||
"archive": "local:backup/vzdump-qemu-101-2024_01_01-00_00_00.vma.zst"}
|
||||
|
||||
assert client.post("/proxmox/api/backups/restore", json=body,
|
||||
headers=user_headers).status_code == 403
|
||||
body["new_vmid"] = 900
|
||||
body["archive"] = "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
assert client.post("/proxmox/api/backups/restore", json=body,
|
||||
headers=user_headers).status_code == 403
|
||||
|
||||
|
||||
def test_bulk_restore_cannot_overwrite_foreign_guest(client, db_session, seed_users, user_headers,
|
||||
server, guests, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], backup__restore=True)
|
||||
body = {"server_id": server.id, "mode": "in_place", "items": [
|
||||
{"node": "pve1", "vmid": 101, "vm_type": "qemu",
|
||||
"archive": "local:backup/vzdump-qemu-101-2024_01_01-00_00_00.vma.zst"}]}
|
||||
|
||||
resp = client.post("/proxmox/api/backups/restore-bulk", json=body, headers=user_headers)
|
||||
|
||||
assert resp.status_code == 403
|
||||
mock_proxmox["client"].restore_vm.assert_not_called()
|
||||
|
||||
|
||||
def test_tenant_cannot_delete_foreign_backup(client, db_session, seed_users, user_headers,
|
||||
server, guests, mock_proxmox):
|
||||
_grant(db_session, seed_users["user"], backup__delete=True)
|
||||
seed_users["user"].assigned_servers.append(server)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.request(
|
||||
"DELETE", f"/proxmox/api/backups/{server.id}/backup", headers=user_headers,
|
||||
json={"node": "pve1", "storage": "local",
|
||||
"volid": "local:backup/vzdump-qemu-101-2024_01_01-00_00_00.vma.zst"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
mock_proxmox["client"].delete_backup.assert_not_called()
|
||||
|
||||
|
||||
def test_backup_job_vmids_are_authorized(client, db_session, seed_users, user_headers, server, guests):
|
||||
_grant(db_session, seed_users["user"], backup__manage=True)
|
||||
base = {"server_id": server.id, "node": "pve1", "storage": "local",
|
||||
"cron_expression": "0 3 * * *"}
|
||||
url = "/proxmox/api/backups/jobs"
|
||||
|
||||
assert client.post(url, json={**base, "vmids": [101]}, headers=user_headers).status_code == 403
|
||||
assert client.post(url, json={**base, "vmids": ["all"]}, headers=user_headers).status_code == 403
|
||||
assert client.post(url, json={**base, "vmids": ["x"]}, headers=user_headers).status_code == 400
|
||||
assert client.post(url, json={**base, "vmids": [100], "cron_expression": "61 * * * *"},
|
||||
headers=user_headers).status_code == 400
|
||||
resp = client.post(url, json={**base, "vmids": ["100"]}, headers=user_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["job"]["vmids"] == [100]
|
||||
|
||||
|
||||
def test_backup_job_toggle_and_run_require_ownership(client, db_session, seed_users, user_headers, server):
|
||||
from app.models import BackupJob
|
||||
_grant(db_session, seed_users["user"], backup__manage=True, backup__create=True)
|
||||
job = BackupJob(server_id=server.id, node="pve1", vmids=[101], storage="local",
|
||||
cron_expression="0 3 * * *", enabled=True, owner_id=seed_users["admin"].id)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
assert client.patch(f"/proxmox/api/backups/jobs/{job.id}/toggle",
|
||||
headers=user_headers).status_code == 403
|
||||
assert client.post(f"/proxmox/api/backups/jobs/{job.id}/run-now",
|
||||
headers=user_headers).status_code == 403
|
||||
db_session.refresh(job)
|
||||
assert job.enabled is True
|
||||
112
backend/tests/test_ssh_host_keys.py
Normal file
112
backend/tests/test_ssh_host_keys.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""SSH host keys: trust on first use, refuse a changed key, reset by an admin."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from app import ssh_client as sc
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def known_hosts(tmp_path, monkeypatch):
|
||||
from app.config import settings
|
||||
path = tmp_path / "ssh_known_hosts"
|
||||
monkeypatch.setattr(settings, "SSH_KNOWN_HOSTS_FILE", str(path))
|
||||
monkeypatch.setattr(settings, "SSH_REJECT_UNKNOWN_HOSTS", False)
|
||||
sc.SSHClient._connections.clear()
|
||||
sc.SSHClient._connection_times.clear()
|
||||
return path
|
||||
|
||||
|
||||
def _key():
|
||||
return paramiko.ECDSAKey.generate()
|
||||
|
||||
|
||||
def test_first_connection_pins_key(known_hosts):
|
||||
key = _key()
|
||||
client = paramiko.SSHClient()
|
||||
|
||||
sc._TrustOnFirstUsePolicy().missing_host_key(client, "10.0.0.5", key)
|
||||
|
||||
assert known_hosts.is_file()
|
||||
pinned = sc.pinned_host_keys("10.0.0.5")
|
||||
assert pinned == [{"type": key.get_name(), "fingerprint": sc.key_fingerprint(key)}]
|
||||
assert client.get_host_keys().lookup("10.0.0.5")
|
||||
|
||||
|
||||
def test_non_default_port_uses_bracket_entry():
|
||||
assert sc.host_key_entry("10.0.0.5", 22) == "10.0.0.5"
|
||||
assert sc.host_key_entry("10.0.0.5", 2222) == "[10.0.0.5]:2222"
|
||||
|
||||
|
||||
def test_pinned_key_is_loaded_and_tofu_policy_set_on_connect():
|
||||
key = _key()
|
||||
sc.pin_host_key("10.0.0.6", key)
|
||||
seen = {}
|
||||
|
||||
def fake_connect(self, **kwargs):
|
||||
seen["known"] = self.get_host_keys().lookup("10.0.0.6")
|
||||
seen["policy"] = type(self._policy).__name__
|
||||
|
||||
with patch.object(paramiko.SSHClient, "connect", fake_connect):
|
||||
assert sc.SSHClient("10.0.0.6", password="x").connect() is True
|
||||
|
||||
assert seen["known"] and seen["known"][key.get_name()] == key
|
||||
assert seen["policy"] == "_TrustOnFirstUsePolicy"
|
||||
|
||||
|
||||
def test_changed_key_is_refused_without_retries():
|
||||
expected, presented = _key(), _key()
|
||||
calls = MagicMock(side_effect=paramiko.BadHostKeyException("10.0.0.7", presented, expected))
|
||||
|
||||
with patch.object(paramiko.SSHClient, "connect", calls):
|
||||
ssh = sc.SSHClient("10.0.0.7", password="x", max_retries=3)
|
||||
assert ssh.connect() is False
|
||||
|
||||
assert calls.call_count == 1
|
||||
assert "does not match the pinned key" in ssh.last_error
|
||||
assert sc.key_fingerprint(presented) in ssh.last_error
|
||||
|
||||
|
||||
def test_forget_removes_pin():
|
||||
sc.pin_host_key("10.0.0.8", _key())
|
||||
sc.pin_host_key("10.0.0.9", _key())
|
||||
|
||||
assert sc.forget_host_key("10.0.0.8") == 1
|
||||
assert sc.pinned_host_keys("10.0.0.8") == []
|
||||
assert sc.pinned_host_keys("10.0.0.9") != []
|
||||
assert sc.forget_host_key("10.0.0.8") == 0
|
||||
|
||||
|
||||
def test_strict_mode_keeps_reject_policy(monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "SSH_REJECT_UNKNOWN_HOSTS", True)
|
||||
seen = {}
|
||||
|
||||
def fake_connect(self, **kwargs):
|
||||
seen["policy"] = type(self._policy).__name__
|
||||
|
||||
with patch.object(paramiko.SSHClient, "connect", fake_connect):
|
||||
sc.SSHClient("10.0.0.10", password="x").connect()
|
||||
|
||||
assert seen["policy"] == "RejectPolicy"
|
||||
|
||||
|
||||
def test_reset_endpoint(client, admin_headers, db_session):
|
||||
from app.models import ProxmoxServer
|
||||
srv = ProxmoxServer(name="pve-hk", hostname="pve-hk.local", ip_address="10.0.0.11", port=8006,
|
||||
api_user="root@pam", use_password=True, password="secret", verify_ssl=False)
|
||||
db_session.add(srv)
|
||||
db_session.commit()
|
||||
sc.pin_host_key("10.0.0.11", _key())
|
||||
|
||||
shown = client.get(f"/proxmox/api/servers/{srv.id}/ssh-host-key", headers=admin_headers)
|
||||
assert shown.status_code == 200
|
||||
by_host = {h["host"]: h["keys"] for h in shown.json()["hosts"]}
|
||||
assert by_host["10.0.0.11"] and by_host["pve-hk.local"] == []
|
||||
|
||||
reset = client.delete(f"/proxmox/api/servers/{srv.id}/ssh-host-key", headers=admin_headers)
|
||||
assert reset.status_code == 200 and reset.json()["removed"] == 1
|
||||
assert sc.pinned_host_keys("10.0.0.11") == []
|
||||
33
backend/tests/test_ssh_write_file.py
Normal file
33
backend/tests/test_ssh_write_file.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""write_file_commands: payloads must fit one shell argument (MAX_ARG_STRLEN)."""
|
||||
import base64
|
||||
import re
|
||||
|
||||
from app.ssh_client import write_file_commands
|
||||
|
||||
_MAX_ARG_STRLEN = 128 * 1024
|
||||
_CHUNK_RE = re.compile(r"printf '%s' '([A-Za-z0-9+/=]*)' \| base64 -d (>>?) /tmp/x$")
|
||||
|
||||
|
||||
def _replay(cmds):
|
||||
out = b""
|
||||
for cmd in cmds:
|
||||
m = _CHUNK_RE.search(cmd)
|
||||
assert m, cmd
|
||||
chunk = base64.b64decode(m.group(1))
|
||||
out = chunk if m.group(2) == ">" else out + chunk
|
||||
return out
|
||||
|
||||
|
||||
def test_large_payload_is_split_below_argument_limit():
|
||||
data = bytes(range(256)) * 2048 # 512 KiB, the App Store per-file limit
|
||||
cmds = write_file_commands("/tmp/x", data)
|
||||
|
||||
assert len(cmds) > 1
|
||||
assert all(len(c.encode()) < _MAX_ARG_STRLEN for c in cmds)
|
||||
assert _replay(cmds) == data
|
||||
|
||||
|
||||
def test_small_and_empty_payloads():
|
||||
assert _replay(write_file_commands("/tmp/x", b"hello")) == b"hello"
|
||||
assert len(write_file_commands("/tmp/x", b"hello")) == 1
|
||||
assert write_file_commands("/tmp/x", b"") == ["umask 077 && : > /tmp/x"]
|
||||
88
backend/tests/test_vm_cache_sync.py
Normal file
88
backend/tests/test_vm_cache_sync.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""sync_vm_cache: upsert from Proxmox, keep ownership, soft-delete vanished guests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import ProxmoxServer, VMInstance
|
||||
from app.workers.monitoring_worker import MonitoringWorker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(db_session):
|
||||
srv = ProxmoxServer(
|
||||
name="pve-sync", hostname="pve-sync.local", ip_address="10.0.0.9", port=8006,
|
||||
api_user="root@pam", use_password=True, password="secret", verify_ssl=False,
|
||||
is_online=True,
|
||||
)
|
||||
db_session.add(srv)
|
||||
db_session.commit()
|
||||
db_session.refresh(srv)
|
||||
return srv
|
||||
|
||||
|
||||
def _fake_client(resources):
|
||||
client = MagicMock()
|
||||
client.is_connected.return_value = True
|
||||
client.get_nodes.return_value = [{"node": "pve1"}]
|
||||
client.get_cluster_resources.return_value = resources
|
||||
client.get_container_interfaces.return_value = []
|
||||
return client
|
||||
|
||||
|
||||
def _run_sync(monkeypatch, resources):
|
||||
worker = MonitoringWorker()
|
||||
client = _fake_client(resources)
|
||||
monkeypatch.setattr(worker, "_create_proxmox_client", lambda server: client)
|
||||
worker.sync_vm_cache()
|
||||
return client
|
||||
|
||||
|
||||
def test_sync_upserts_and_preserves_owner(db_session, server, seed_users, monkeypatch):
|
||||
owner = seed_users["user"]
|
||||
db_session.add(VMInstance(server_id=server.id, vmid=100, node="pve1", vm_type="qemu",
|
||||
name="old-name", status="stopped", owner_id=owner.id))
|
||||
db_session.commit()
|
||||
|
||||
_run_sync(monkeypatch, [
|
||||
{"vmid": 100, "type": "qemu", "name": "web", "node": "pve1", "status": "running",
|
||||
"maxmem": 2 * 1024 ** 3, "maxdisk": 10 * 1024 ** 3, "maxcpu": 2},
|
||||
{"vmid": 200, "type": "lxc", "name": "ct", "node": "pve1", "status": "stopped"},
|
||||
])
|
||||
|
||||
db_session.expire_all()
|
||||
rows = {r.vmid: r for r in db_session.query(VMInstance).filter_by(server_id=server.id)}
|
||||
assert set(rows) == {100, 200}
|
||||
assert rows[100].name == "web" and rows[100].status == "running"
|
||||
assert rows[100].owner_id == owner.id
|
||||
assert rows[100].memory == 2048
|
||||
assert rows[200].vm_type == "lxc"
|
||||
|
||||
|
||||
def test_sync_soft_deletes_vanished_guest(db_session, server, monkeypatch):
|
||||
db_session.add(VMInstance(server_id=server.id, vmid=300, node="pve1", vm_type="qemu",
|
||||
name="gone", status="running"))
|
||||
db_session.commit()
|
||||
|
||||
_run_sync(monkeypatch, [{"vmid": 301, "type": "qemu", "name": "stays", "node": "pve1",
|
||||
"status": "running"}])
|
||||
|
||||
db_session.expire_all()
|
||||
gone = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=300).one()
|
||||
assert gone.deleted_at is not None
|
||||
|
||||
|
||||
def test_sync_creates_client_once_per_server(db_session, server, monkeypatch):
|
||||
worker = MonitoringWorker()
|
||||
client = _fake_client([])
|
||||
created = []
|
||||
|
||||
def _create(srv):
|
||||
created.append(srv.id)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(worker, "_create_proxmox_client", _create)
|
||||
worker.sync_vm_cache()
|
||||
|
||||
assert created == [server.id]
|
||||
49
backend/tests/test_ws_watchdog.py
Normal file
49
backend/tests/test_ws_watchdog.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Long-lived console sockets close once their session or permission is gone."""
|
||||
import asyncio
|
||||
|
||||
from app.auth import watch_ws_access, ws_access_still_valid
|
||||
from app.services.security_service import SecurityService
|
||||
|
||||
|
||||
def test_access_revoked_by_session_termination(db_session, seed_users):
|
||||
user = seed_users["user"]
|
||||
session, _ = SecurityService.create_session(db_session, user, "127.0.0.1", "pytest")
|
||||
|
||||
assert ws_access_still_valid(user.id, session.session_token)
|
||||
SecurityService.terminate_session(db_session, session.session_token)
|
||||
assert not ws_access_still_valid(user.id, session.session_token)
|
||||
|
||||
|
||||
def test_watchdog_check_does_not_extend_the_session(db_session, seed_users):
|
||||
from datetime import timedelta
|
||||
from app.config import utcnow
|
||||
|
||||
user = seed_users["user"]
|
||||
session, _ = SecurityService.create_session(db_session, user, "127.0.0.1", "pytest")
|
||||
session.last_activity = utcnow() - timedelta(hours=1)
|
||||
expires = utcnow() + timedelta(minutes=1)
|
||||
session.expires_at = expires
|
||||
db_session.commit()
|
||||
|
||||
assert ws_access_still_valid(user.id, session.session_token)
|
||||
db_session.expire_all()
|
||||
assert abs((session.expires_at.replace(tzinfo=None) - expires.replace(tzinfo=None)).total_seconds()) < 1
|
||||
|
||||
|
||||
def test_access_revoked_by_permission_check(db_session, seed_users):
|
||||
user = seed_users["user"]
|
||||
assert ws_access_still_valid(user.id, None, lambda db, u: True)
|
||||
assert not ws_access_still_valid(user.id, None, lambda db, u: False)
|
||||
|
||||
user.is_active = False
|
||||
db_session.commit()
|
||||
assert not ws_access_still_valid(user.id, None)
|
||||
|
||||
|
||||
def test_watchdog_returns_when_access_ends(db_session, seed_users):
|
||||
user = seed_users["user"]
|
||||
session, _ = SecurityService.create_session(db_session, user, "127.0.0.1", "pytest")
|
||||
SecurityService.terminate_session(db_session, session.session_token)
|
||||
|
||||
asyncio.run(asyncio.wait_for(
|
||||
watch_ws_access(user.id, session.session_token, interval=0.01), timeout=5))
|
||||
@@ -41,7 +41,10 @@ services:
|
||||
pull_policy: build
|
||||
container_name: pvemanager-app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# Loopback only: the UI reaches the API through the frontend's nginx on
|
||||
# the compose network. Published on all interfaces, the API bypassed it,
|
||||
# and a LAN client (a private peer) could set X-Forwarded-For freely.
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
|
||||
19
deploy.sh
19
deploy.sh
@@ -380,10 +380,29 @@ setup_nginx_config() {
|
||||
|
||||
# Replace domain name
|
||||
sed -i "s/DOMAIN_NAME/${domain}/g" nginx/conf.d/pvemanager.conf
|
||||
|
||||
# Public URL for links in e-mails (password reset). Without it the backend
|
||||
# falls back to request headers, which an attacker controls.
|
||||
set_panel_url "$domain" "$use_ssl"
|
||||
|
||||
print_success "NGINX configuration created for domain: ${domain}"
|
||||
}
|
||||
|
||||
# Write PANEL_URL=<scheme>://<domain> into backend/.env (replace or append).
|
||||
set_panel_url() {
|
||||
local domain=$1
|
||||
local use_ssl=$2
|
||||
local scheme="http"
|
||||
[ "$use_ssl" = true ] && scheme="https"
|
||||
[ -f backend/.env ] || return 0
|
||||
if grep -q '^PANEL_URL=' backend/.env; then
|
||||
sed -i "s|^PANEL_URL=.*|PANEL_URL=${scheme}://${domain}|" backend/.env
|
||||
else
|
||||
printf '\nPANEL_URL=%s://%s\n' "$scheme" "$domain" >> backend/.env
|
||||
fi
|
||||
print_info "PANEL_URL set to ${scheme}://${domain}"
|
||||
}
|
||||
|
||||
# Check whether a usable certificate for the domain is already on disk.
|
||||
#
|
||||
# The check has to run as root inside a container: certbot creates
|
||||
|
||||
@@ -11,3 +11,4 @@ dist
|
||||
*.md
|
||||
coverage
|
||||
.DS_Store
|
||||
graphify-out
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# WebSocket URLs carry the session JWT in ?token= (browsers cannot send
|
||||
# headers on WebSocket requests): log those paths without the query string.
|
||||
log_format ws_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" '
|
||||
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -9,6 +14,22 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# index.html references content-hashed bundles: it must be revalidated on
|
||||
# every load, otherwise browsers keep a heuristically cached copy after a
|
||||
# deploy and request bundles that no longer exist (blank page until a hard
|
||||
# refresh).
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
# The SPA document is served here in every deployment mode (the outer
|
||||
# nginx proxies to this one, and its http-level headers are not
|
||||
# inherited by servers that set their own add_header). Without these
|
||||
# the panel could be framed by any site (clickjacking).
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://app:8000/api/;
|
||||
@@ -94,6 +115,7 @@ server {
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws/ {
|
||||
access_log /var/log/nginx/access.log ws_noquery;
|
||||
proxy_pass http://app:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -105,6 +127,7 @@ server {
|
||||
|
||||
# Proxmox WebSocket proxy
|
||||
location /proxmox/ws/ {
|
||||
access_log /var/log/nginx/access.log ws_noquery;
|
||||
proxy_pass http://app:8000/proxmox/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/auth/auth-bg-dark.webp
Normal file
BIN
frontend/src/assets/auth/auth-bg-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
frontend/src/assets/auth/auth-bg-light.webp
Normal file
BIN
frontend/src/assets/auth/auth-bg-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -56,13 +56,20 @@ export default function CommandPalette() {
|
||||
];
|
||||
}, [query, isAdmin, permissions, t]);
|
||||
|
||||
// Reset transient UI state whenever the dialog's open state changes, driven
|
||||
// straight from the event (not an effect) to avoid a cascading re-render.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
};
|
||||
// Start every opening with an empty query. Done while rendering (not in an
|
||||
// effect, not in onOpenChange): Ctrl+K, the top-bar button and go() flip the
|
||||
// store directly and bypass onOpenChange, so the previous search used to
|
||||
// reappear on the next open.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => setOpen(next);
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
|
||||
24
frontend/src/components/shared/GroupIcon.tsx
Normal file
24
frontend/src/components/shared/GroupIcon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Иконка группы ОС-шаблонов.
|
||||
*
|
||||
* Иконки хранятся строкой: либо эмодзи/текст, либо разметка вида
|
||||
* `<i class="fa-brands fa-ubuntu os-icon"></i>`. Раньше строка вставлялась через
|
||||
* dangerouslySetInnerHTML, а её может задать любой пользователь с template:manage
|
||||
* — это был stored XSS против всех, кто открывает шаблоны (токен сессии лежит в
|
||||
* браузере). Теперь из разметки берётся только тег i/span и его классы.
|
||||
*/
|
||||
const TAG_RE = /^\s*<(i|span)\s+class="([\w\s-]*)"\s*>\s*<\/\1>\s*$/;
|
||||
|
||||
export function GroupIcon({ icon, className = 'text-lg' }: { icon: string; className?: string }) {
|
||||
const m = TAG_RE.exec(icon);
|
||||
if (m) {
|
||||
const Tag = m[1] as 'i' | 'span';
|
||||
return (
|
||||
<span className={className}>
|
||||
<Tag className={m[2]} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Не разметка — показываем как текст (эмодзи). Любые теги React экранирует.
|
||||
return <span className={className}>{icon.includes('<') ? '' : icon}</span>;
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export default function NotificationsDropdown() {
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{t('notifications.empty')}</div>
|
||||
) : (
|
||||
notifications.slice(0, 20).map(n => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" onSelect={e => e.preventDefault()}>
|
||||
// closeOnClick={false}: the mark-read / delete buttons live inside the
|
||||
// item, so a click on them must not close the whole menu (Base UI has
|
||||
// no Radix-style onSelect to cancel).
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" closeOnClick={false}>
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm leading-tight ${n.is_read ? 'text-muted-foreground' : 'font-medium'}`}>
|
||||
|
||||
@@ -62,6 +62,18 @@ export function RequirePermission({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page ("/", unknown URLs, after login or an identity switch): the
|
||||
* first page the user may open. Sending everybody to /dashboard made users
|
||||
* without dashboard:view bounce off RequirePermission with an "access denied"
|
||||
* toast on every sign-in.
|
||||
*/
|
||||
export function HomeRedirect() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (!user) return <Spinner />;
|
||||
return <Navigate to={firstAccessiblePath(!!user.is_admin, user.permissions) ?? '/profile'} replace />;
|
||||
}
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
@@ -80,7 +92,7 @@ export function PublicRoute() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -407,7 +407,7 @@ export default function InstallWizard({ app, open, onOpenChange, resumeInstalled
|
||||
{t('appstore.done')}
|
||||
</div>
|
||||
{installedApp?.url && (
|
||||
<Button variant="outline" className="w-full" onClick={() => window.open(installedApp.url!, '_blank')}>
|
||||
<Button variant="outline" className="w-full" onClick={() => window.open(installedApp.url!, '_blank', 'noopener,noreferrer')}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{installedApp.url}
|
||||
</Button>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
||||
setTwoFactorRequired(true);
|
||||
return;
|
||||
}
|
||||
navigate('/dashboard');
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Error is already handled in store
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Play, Calendar, RotateCcw, Trash2, Loader2, Plus, Pencil } from 'lucide-react';
|
||||
|
||||
@@ -99,15 +99,40 @@ function detectVmType(volid: string): 'qemu' | 'lxc' {
|
||||
export default function BackupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState('files');
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [serverPick, setSelectedServer] = useState<string>('');
|
||||
const [selectedNode, setSelectedNode] = useState<string>(ALL_NODES);
|
||||
const [selectedStorage, setSelectedStorage] = useState('');
|
||||
const [storagePick, setSelectedStorage] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: servers = [] } = useServers();
|
||||
// The picked server, or the first one when nothing (or a server no longer in
|
||||
// the active workspace) is picked — a stale id would silently empty the list
|
||||
// and make the Select show the raw id instead of a name.
|
||||
const selectedServer = servers.some(s => String(s.id) === serverPick)
|
||||
? serverPick
|
||||
: (servers[0] ? String(servers[0].id) : '');
|
||||
const sid = selectedServer ? Number(selectedServer) : 0;
|
||||
// A different server (picked, or fallen back to) invalidates the node choice.
|
||||
const [nodesOfServer, setNodesOfServer] = useState(selectedServer);
|
||||
if (nodesOfServer !== selectedServer) {
|
||||
setNodesOfServer(selectedServer);
|
||||
setSelectedNode(ALL_NODES);
|
||||
setSelectedStorage('');
|
||||
}
|
||||
const { data: nodesData } = useNodes(sid);
|
||||
const { data: storagesData } = useBackupStorages(sid);
|
||||
const allStorages = useMemo(
|
||||
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
|
||||
[storagesData],
|
||||
);
|
||||
const storages = useMemo(
|
||||
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
|
||||
[allStorages],
|
||||
);
|
||||
// The picked backup storage, or the first backup-capable one.
|
||||
const selectedStorage = storages.some(s => s.storage === storagePick)
|
||||
? storagePick
|
||||
: (storages[0]?.storage ?? '');
|
||||
const nodeParam = selectedNode === ALL_NODES ? '' : selectedNode;
|
||||
const { data: backupsData } = useBackupList(sid, nodeParam, selectedStorage);
|
||||
const { data: jobsData } = useBackupJobs();
|
||||
@@ -151,14 +176,6 @@ export default function BackupsPage() {
|
||||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||
|
||||
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
|
||||
const allStorages = useMemo(
|
||||
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
|
||||
[storagesData],
|
||||
);
|
||||
const storages = useMemo(
|
||||
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
|
||||
[allStorages],
|
||||
);
|
||||
// Storages that can hold restored guest disks on a given node.
|
||||
// qemu => content 'images', lxc => 'rootdir', 'any' => either (bulk, mixed types).
|
||||
const diskStoragesFor = (node: string, vmType: 'qemu' | 'lxc' | 'any') => {
|
||||
@@ -171,7 +188,7 @@ export default function BackupsPage() {
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const allBackups = (backupsData?.backups || []) as BackupItem[];
|
||||
const allBackups = useMemo(() => (backupsData?.backups || []) as BackupItem[], [backupsData]);
|
||||
const backups = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return allBackups;
|
||||
@@ -224,36 +241,6 @@ export default function BackupsPage() {
|
||||
return map;
|
||||
}, [servers]);
|
||||
|
||||
// Auto-select first server when list arrives
|
||||
useEffect(() => {
|
||||
if (!selectedServer && servers.length > 0) {
|
||||
setSelectedServer(String(servers[0].id));
|
||||
}
|
||||
}, [servers, selectedServer]);
|
||||
|
||||
// Drop a stale server selection when its server is not in the active
|
||||
// workspace anymore (e.g. after switching workspaces) — otherwise the list
|
||||
// silently empties and the Select shows the raw server id instead of a name.
|
||||
useEffect(() => {
|
||||
if (selectedServer && servers.length > 0 && !servers.some(s => String(s.id) === selectedServer)) {
|
||||
setSelectedServer('');
|
||||
setSelectedNode(ALL_NODES);
|
||||
setSelectedStorage('');
|
||||
}
|
||||
}, [servers, selectedServer]);
|
||||
|
||||
// Auto-select first backup-capable storage when none selected (or current is unavailable)
|
||||
useEffect(() => {
|
||||
if (!sid) return;
|
||||
if (storages.length === 0) {
|
||||
if (selectedStorage) setSelectedStorage('');
|
||||
return;
|
||||
}
|
||||
if (!selectedStorage || !storages.some(s => s.storage === selectedStorage)) {
|
||||
setSelectedStorage(storages[0].storage);
|
||||
}
|
||||
}, [storages, sid, selectedStorage]);
|
||||
|
||||
const openRestore = (b: BackupItem) => {
|
||||
setRestoreTarget(b);
|
||||
setRestoreTargetNode(b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '');
|
||||
@@ -300,9 +287,12 @@ export default function BackupsPage() {
|
||||
|
||||
// --- Bulk restore selection helpers (one archive per VMID) ---
|
||||
// Reset selection whenever the visible backup set changes (server/node/storage switch).
|
||||
useEffect(() => {
|
||||
const backupSetKey = `${sid}|${nodeParam}|${selectedStorage}`;
|
||||
const [selectionSetKey, setSelectionSetKey] = useState(backupSetKey);
|
||||
if (selectionSetKey !== backupSetKey) {
|
||||
setSelectionSetKey(backupSetKey);
|
||||
setSelectedBackups(new Map());
|
||||
}, [sid, nodeParam, selectedStorage]);
|
||||
}
|
||||
|
||||
const toggleBackupSelected = (b: BackupItem) => {
|
||||
setSelectedBackups(prev => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle, Search } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -357,9 +357,12 @@ function NativeJobDialog({ serverId, open, onOpenChange, editing, backupStorages
|
||||
const update = useUpdateProxmoxJob(serverId);
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
// Refill the form each time the dialog opens (or is pointed at another job).
|
||||
const [filledFor, setFilledFor] = useState({ open: false, editing: null as NativeJob | null });
|
||||
if (filledFor.open !== open || filledFor.editing !== editing) {
|
||||
setFilledFor({ open, editing });
|
||||
if (open) setForm(jobToForm(editing));
|
||||
}, [open, editing]);
|
||||
}
|
||||
|
||||
const set = <K extends keyof NativeJobForm>(k: K, v: NativeJobForm[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
@@ -494,11 +497,11 @@ export default function StoragesTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: servers = [] } = useServers();
|
||||
const [serverId, setServerId] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId === 0 && servers.length > 0) setServerId(servers[0].id);
|
||||
}, [servers, serverId]);
|
||||
const [serverPick, setServerId] = useState(0);
|
||||
// Выбранный сервер или первый из списка. Протухший выбор (сервера нет в
|
||||
// активной рабочей области, например после её переключения) не держим —
|
||||
// иначе Select показывает голый id сервера вместо имени.
|
||||
const serverId = servers.some((s) => s.id === serverPick) ? serverPick : (servers[0]?.id ?? 0);
|
||||
|
||||
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
||||
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user