10 Commits

Author SHA1 Message Date
markmorado
636614955d fix(ssh): use stored root password for node SSH with API-token auth
Some checks are pending
Backend Tests / pytest (Python 3.12) (push) Waiting to run
Deploy to Production / backend-tests (push) Waiting to run
Deploy to Production / frontend-build (push) Waiting to run
Deploy to Production / Trigger production update (push) Blocked by required conditions
SonarCloud / SonarCloud Analysis (push) Waiting to run
ProxmoxClient.from_server only passed the stored password to the client in
password-auth mode, so servers using an API token (including auto-provisioned
ones that keep the password for SSH fallbacks) connected to the node with no
credentials. Golden template builds, App Store installs and other root SSH
operations failed with "No authentication methods available".

- Keep the stored password on token-auth clients for SSH use only
- Fail fast on "No authentication methods available" with an actionable
  hint instead of retrying with backoff
- Add regression tests
2026-11-15 00:01:42 +05:00
markmorado
32434e0ef3 refactor(frontend): drop setState-in-effect patterns
Some checks failed
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Replace all 57 react-hooks/set-state-in-effect sites with the patterns
React recommends:

- dialog forms reset while rendering when the dialog opens, instead of
  in an effect after a frame with stale values;
- forms filled from server data resync during render when a new
  response object arrives;
- default selections (server, node, storage, disk, user) are derived
  from the user's pick and the current list rather than written back
  into state.

This also fixes the migrate dialogs resetting the chosen target when
the guest status refreshed, and the image download dialog resetting
"convert to template" on every render while storages were loading.
Unstable `?? []` / `new Set()` hook dependencies are memoized.
2026-10-23 01:51:57 +05:00
markmorado
990ca14c63 fix(frontend): routing, session state, console and power-action bugs
- Land users on their first accessible page instead of /dashboard, so
  users without dashboard:view no longer get an "access denied" toast
  on every sign-in, impersonation switch or unknown URL.
- Reset the active workspace and the persisted deploy/minimized/bulk
  task trays on logout and impersonation, so the next identity in the
  browser does not see the previous one's tasks or workspace.
- Keep the session when /api/auth/me fails with a network error or 5xx
  (panel restarting) and retry, instead of dropping the token.
- Notifications: mark-read/delete buttons no longer close the menu.
- Command palette starts every opening with an empty query.
- Live instance metrics no longer show the previous instance's data
  after switching instances.
- reset/resume are expected to leave the guest running (no "stopped"
  flash and no 36 s of pointless polling).
- Execute-script goes through apiClient (workspace header, 401 handling,
  readable validation errors).
- Node shell no longer reconnects (re-running apt dist-upgrade) when
  the translation function changes; fullscreen icon follows Esc exits.
- Node power/certificate actions use hasPermission so role-bound admins
  see them; power toasts are translated.
2026-10-23 01:51:53 +05:00
markmorado
09186e23b9 Security audit: tenant isolation, injection fixes, hardening and performance
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security
- Enforce guest ownership and server assignment across Proxmox endpoints
  (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy,
  LXC/ISO creation, golden template) and WebSocket channels
- Keep the panel's PVE root ticket server-side for consoles; single-use
  WebSocket tickets instead of the JWT in URLs; re-check access on open
  WebSocket bridges
- Validate identifiers that reach Proxmox API paths and node shell
  commands; reject backup archives / foreign volumes in create requests
- Close privilege escalation in user management, TOTP replay, reset-link
  poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos)
- SSH host key pinning (TOFU), encrypted 2FA seeds and notification
  secrets, password changes end other sessions, redact secrets in logs
- Quotas count in-flight creations; 2FA failures count toward lockout

Fixes
- Consoles on non-default API ports, large App Store/script files,
  interrupted tasks after restart, impersonation lost on token refresh,
  HTTPExceptions swallowed into 500s, WebSocket client reconnects

Performance and process
- Fewer DB writes and queries on hot paths, lazy-loaded locales and
  heavy pages, off-loop blocking work, indexes (migrations 44-46)
- Deploy waits for backend tests and frontend build; frontend checks
  workflow; API bound to loopback without the SSL profile
2026-10-23 00:04:16 +05:00
bd748eae8f feat(templates): make Group and Server columns sortable
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m45s
Same SortHeader toggle already used for Type, Name, id, Updated and
Size — Group and Server only had a filter icon before.
2026-08-25 13:28:29 +05:00
b87a79bc83 feat(templates): make the Type column sortable in OS Templates table
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m47s
The Type column only had a filter icon; add the same sort toggle the
other columns use so KVM/LXC rows can be sorted, not just filtered.
2026-08-25 13:07:57 +05:00
1f00fddd22 feat(templates): render VM Images as a table, fix stale server selection
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m46s
Match the VM Images tab's layout to the OS Templates tab (sortable
table instead of a card grid) instead of duplicating its own look.

Also drop the auto-selected server id when a workspace switch
invalidates the server list — it used to stick around and the picker
fell back to showing the raw numeric id instead of a name.
2026-08-25 12:47:18 +05:00
4423e00b66 fix(backups): drop stale server selection after a workspace switch
Storages tab kept the previously auto-selected server id even after
switching workspaces invalidated the server list, so the picker fell
back to rendering the raw numeric id instead of a name. Reset the
selection when it's no longer in the current server list, matching
the guard already used on the Backups and Instances pages.
2026-08-25 12:47:11 +05:00
1d7b22f4d1 Revert "fix(templates): drop the VM Images tab, it duplicated OS Templates"
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m4s
This reverts commit f56c46b587.
2026-08-25 11:41:45 +05:00
f56c46b587 fix(templates): drop the VM Images tab, it duplicated OS Templates
Both tabs listed the same qcow2 cloud images already covered by the
OS Templates tab; keep just LXC Templates, ISO Images and
Repositories alongside it.
2026-08-25 11:39:09 +05:00
157 changed files with 5186 additions and 1130 deletions

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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"`. Панель же, наткнувшись на запрет, объясняет в тексте ошибки, что и где поправить в госте

View File

@@ -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/

View File

@@ -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

View File

@@ -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/* \

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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.

View File

@@ -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):

View File

@@ -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

View File

@@ -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 ====================

View File

@@ -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):

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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:

View File

@@ -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

View File

@@ -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))

View File

@@ -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):

View File

@@ -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):

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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

View File

@@ -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.

View File

@@ -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))

View File

@@ -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(

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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]:

View File

@@ -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

View File

@@ -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"))
):

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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"""

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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)."""

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -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"""

View File

@@ -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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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"),

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View 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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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():

View File

@@ -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)

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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(

View 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"

View 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"}

View 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()

View 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

View 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"

View 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

View 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

View 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") == []

View 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"]

View 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]

View 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))

View File

@@ -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

View File

@@ -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

View File

@@ -11,3 +11,4 @@ dist
*.md
coverage
.DS_Store
graphify-out

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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);

View 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>;
}

View File

@@ -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'}`}>

View File

@@ -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 />;

View File

@@ -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>

View File

@@ -25,7 +25,7 @@ export default function LoginPage() {
setTwoFactorRequired(true);
return;
}
navigate('/dashboard');
navigate('/');
} catch {
// Error is already handled in store
}

View File

@@ -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 => {

View File

@@ -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