128 Commits

Author SHA1 Message Date
0220f338db docs: add CONTRIBUTING and SECURITY links to Russian README
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 11s
2026-08-17 02:00:57 +05:00
985f04c7ad docs: add CONTRIBUTING and SECURITY links to README
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
2026-08-17 01:59:39 +05:00
8b3cde79cf docs: replace template SECURITY.md with project-specific policy
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-17 01:56:57 +05:00
72d933228c docs: add contributing guide for new contributors
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
2026-08-17 01:52:18 +05:00
5999183c8e fix: activate docker group in current session via sg re-exec
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
After install_docker adds the user to the docker group, the change only
takes effect in a new login session.  The script continued running in
the old session, docker info failed, and deploy exited with "Cannot
connect to Docker daemon" even though the daemon was running fine.

Now check_requirements detects this situation (sudo docker works but
docker doesn't, and the user IS in the docker group per /etc/group)
and re-execs the entire script under `sg docker` to activate the
supplementary GID without a logout/login cycle.  A _DEPLOY_REEXEC
guard prevents infinite loops.
2026-08-17 01:42:05 +05:00
286576715b docs: release notes and version bump for v1.16.1
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
2026-08-17 01:26:05 +05:00
820da8ca01 fix: set 755 permissions on pve CLI so non-root users can read the script
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
sudo cp preserves the source umask which can leave the file without
read permission for group/others.  A bash script needs both read and
execute bits — without read, the shell cannot interpret it.
2026-08-17 01:20:43 +05:00
a275b8bed5 fix: prevent SSL rollback by using container state instead of status text
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
verify_nginx_running grepped for "running|healthy" in the human-readable
STATUS column of docker compose ps, but the column shows "Up Xs (health:
starting)" for the first 30s before the healthcheck passes — neither
word matches.  With max_wait=20s the function always timed out and the
deploy script rolled back to the HTTP-only config even though the SSL
certificate was successfully obtained.

Switch to --format '{{.State}}' which returns the raw "running" state
immediately, and bump max_wait to 60s for slow first starts.
2026-08-17 01:13:43 +05:00
fbfbd76960 fix: install pve CLI without root via sudo fallback to ~/.local/bin
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
Previously install_pve_cli silently skipped installation when deploy.sh
was run without root, leaving the 'pve' command unavailable.  Now it
tries sudo first (password is usually cached from docker operations)
and falls back to ~/.local/bin with automatic PATH configuration.
2026-08-17 00:34:58 +05:00
8fa1208cde feat(frontend): switch to Inter Variable, bump type scale, fix TS errors
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
- Replace DM Sans with Inter Variable (better screen rendering at small sizes)
- Bump text-xs 12→13px, text-sm 14→15px globally via theme tokens
- Fix hardcoded text-[13px] → text-sm in AppLayout, CommandPalette, DashboardPage
- Fix button sm variant text-[0.8rem] → text-xs for consistency
- Fix TS: ScriptExecuteDialog unused isRunning, setResult → setExecutionId(r.id)
- Fix TS: InstanceActionDialogs onValueChange null guards (string | null → string)
- Dockerfile: npm ci → npm install (cross-platform lockfile, Linux optional deps)
2026-08-17 00:14:06 +05:00
ec076959db docs: release notes and version bump for v1.16.0
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-14 20:06:12 +05:00
22162408ac fix: correct password visibility icon logic across all dialogs
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-14 19:52:59 +05:00
319de5c96e feat: swap SSH keys when admin changes instance owner
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m21s
Deploy to Production / Trigger production update (push) Failing after 10s
Remove the previous owner's SSH keys and apply the new owner's keys
(profile + library) when ownership is reassigned. If the new owner has
no keys, the old keys are simply removed.

QEMU VMs: updates cloud-init sshkeys via Proxmox API.
LXC containers: writes /root/.ssh/authorized_keys via exec.
The swap is best-effort — failures are logged but do not block the
ownership change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-14 19:07:59 +05:00
f4878eac50 scripts: add patch-cloud-images.sh for building GitHub mirror with qemu-guest-agent
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 11s
2026-08-14 17:02:25 +05:00
4d00fdf959 catalog: point amd64 images to GitHub mirror with qemu-guest-agent and sha256
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m21s
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-14 16:46:28 +05:00
f29226a735 fix: filter out loop devices and system partitions from VM storage overview
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m20s
Deploy to Production / Trigger production update (push) Failing after 10s
Only show user-relevant filesystems by skipping snap/loop mounts
and /boot/* partitions in QEMU guest agent fsinfo response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-14 16:40:01 +05:00
8ff757f8c1 fix: re-download cached import images when checksum is provided
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
volume_exists() only checked file presence, not content. A stale image
from a previous download (different URL/version) would be silently reused,
causing templates and VMs to be created from unpatched images even when
the mirror's checksum pointed to a newer patched build.

Add delete_volume() to ProxmoxClient storage mixin, and update both
_do_image_download_sync and _do_image_template_sync to delete the cached
volume and re-download whenever a checksum is provided, letting Proxmox
verify the new download server-side.
2026-08-14 16:19:41 +05:00
3540291c0e i18n: translate hardcoded Russian strings in graphs, wizard, storages, scripts
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 12s
- GraphsTab: wrap "Все", "Сбросить", "Нет данных", IOPS and disk-pct chart
  titles with t(); add graphs.iops_read/write/disk_usage_pct keys
- CreateInstanceWizard: translate (безопаснее) spans via common.safer;
  translate IP placeholder via wizard.ip_cidr_placeholder
- StoragesTab: translate Ceph monhost placeholder via storages.monhost_placeholder
- ScriptEditDialog: translate name placeholder via scripts.name_placeholder
- Add scripts.execution_title and scripts.no_output keys (EN + RU)
- Add common.safer key (EN + RU)
2026-08-14 14:06:10 +05:00
5af2ffed01 i18n: add missing translation keys for appstore, nodeadm, common, and scripts
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
- appstore.golden.pick_server_hint (EN + RU)
- nodeadm: certs, syslog, tasks, system, tasks_hint, task_start, task_type,
  task_id, task_user, task_running, syslog_hint, tz_saved, dns_saved,
  time, node_time, timezone
- common.changePassword, common.runCommand, common.queued, common.no_data
- Remove duplicate common.no_data key from en.json
2026-08-13 17:26:43 +05:00
d6ea83c8aa i18n(dashboard,instances): translate dashboard, action dialogs and resource cards
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-13 17:19:26 +05:00
43db0f79ca i18n(images): translate Images page, dialogs and mirrors manager
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-13 16:59:01 +05:00
477faef44a i18n: add scripts and certs sections (ru + en)
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
scripts: full section — catalog, repos, executions tabs, edit/execute
         dialogs, community scripts warning, all labels and toasts
certs: full section — upload form, certificate details, expiry badges
2026-08-13 16:43:04 +05:00
5a1fb72660 i18n: add bulk_migrate and access realm keys (ru + en)
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
instances: bulk_migrate_title/desc, target_node, select_node,
           online_migration, status_migrating, nothing_to_migrate,
           migrate_same_server
access: user_attr, username_claim translated to Russian
2026-08-13 16:24:52 +05:00
008e29b70f i18n: add missing nodes power and common action keys (ru + en)
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
nodes: reboot, shutdown, shell, confirm_reboot, confirm_shutdown,
       reboot_started, shutdown_started
common: comment, add, clone, iso, notes, reinstall, done, copied, message
2026-08-13 16:21:30 +05:00
80d7a98256 i18n: fix critical untranslated strings across the UI
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
- DestroyTab: wrap all hardcoded English strings with t()
- instances: add status_reinstalling, status_changing_password keys
- common: add shutdown key (ru + en)
- fw: translate groups, ipsets, aliases, security_group and related actions
- nodeadm: translate upgrade button label
- en.json: fix Russian placeholder_notes in English locale
2026-08-13 16:17:07 +05:00
d88647de77 i18n(roles): add missing permission label translations to Russian
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 18s
node_power, app_view/install/manage, script_view/execute/manage
2026-08-13 15:57:46 +05:00
82a3ae72be i18n(roles): translate remote_migrate permission labels to Russian 2026-08-13 15:56:00 +05:00
a7fda196b5 docs: release notes and version bump for v1.15.0
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
2026-08-13 15:41:27 +05:00
8337539e9e i18n(users): translate the terminated-sessions toast
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m12s
Deploy to Production / Trigger production update (push) Failing after 11s
Both toasts printed the endpoint's own message field verbatim, so a Russian UI
answered "Terminated 0 sessions". That string is assembled in Python and can
never be translated where it is built, so the endpoints now also return the raw
count and the phrase is composed on the client.

message is kept: it goes to the audit log and may have other consumers, and
adding a field breaks no one.

Russian needs three forms, and zero does not land in the obvious one — 0 is the
"many" category ("сессий", not "сессии") — hence four keys against English's
two.

Fixes both call sites: the per-user button in the table row and Terminate all
sessions on the Sessions tab, which shared the same untranslated string.
2026-08-12 15:00:07 +05:00
9e4120bd8d feat(instances): disable Create Instance when the quota is used up
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
The wizard already refuses to open once a limit has no headroom left, but the
button leading to it still looked available, so the only way to find out was to
click it. Reuse exhaustedMetrics() from the wizard's quota module, so the button
and the wizard cannot disagree about what counts as exhausted, and skip the
check for admins for the same reason the wizard does — their own quota says
nothing about an instance they create for someone else.

The button is rendered as a Link, and disabled does nothing to an <a>: it would
still navigate. So the exhausted case renders a plain button with no Link, and a
tooltip naming the limits that are full. The trigger wraps it in a span because
a disabled button receives no pointer events of its own.

The wizard keeps its own block, which is what still covers navigating straight
to /instances/create.
2026-08-12 14:44:42 +05:00
91fa1b9971 fix(nginx): raise limit_conn so HTTP/2 stream count stops throttling the app
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Rebuilding the frontend surfaced the limiter sitting behind the request-rate
one fixed in 935c804: with limit_req no longer rejecting the bundle, the
cold load started failing on `limiting connections by zone "addr"` instead,
and the page went blank again.

nginx counts each concurrent HTTP/2 stream against limit_conn, and a browser
multiplexes the entire page over a single connection, so `limit_conn addr 10`
capped the whole page at ten in-flight requests. That is far below the 89
files the build emits, and it would equally have throttled the parallel API
queries the dashboard issues after login — the login screen just happened to
be where it showed.

Raise the server-level cap to 100 and give the static-asset location 200.
Verified against the live deployment: 89/89 assets return 200, the login
page renders, and nginx has logged no limiting events since the reload.
2026-08-11 21:24:17 +05:00
925b5c8664 chore: mark the shell entry points executable
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
deploy.sh, bootstrap.sh, update_host.sh, setup_ssh.sh and pve all carry a
#!/bin/bash shebang and are invoked directly, but git recorded every one of
them as 100644. A fresh clone therefore could not run ./deploy.sh, and any
host where the bit had been fixed by hand showed a permanently dirty
working tree — the mode was the only difference.

Record the bit so a clone is runnable and `git status` stays clean.
2026-08-11 21:14:16 +05:00
c981a14cf9 chore(nginx): stop tracking the generated site config
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 11s
nginx/conf.d/pvemanager.conf is not source: setup_nginx_config() writes it
on every deploy by copying one of the .template files and substituting the
domain. Tracking it meant the repository carried one deployment's hostname,
and every deploy showed the file as modified.

Untrack it and ignore it. The templates it is generated from stay tracked,
so nothing is lost — a missing copy is restored by re-running deploy.sh, or
by hand:

    cp nginx/conf.d/pvemanager.conf.template nginx/conf.d/pvemanager.conf
    sed -i "s/DOMAIN_NAME/<domain>/g" nginx/conf.d/pvemanager.conf

Note for existing checkouts: this commit deletes the path, so a deployed
host that pulls it will lose its live config unless the file is regenerated
afterwards.
2026-08-11 21:10:09 +05:00
0662dda6ae fix(compose): actually drop the published ports in production
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 11s
compose.prod.yml has always intended to take the app and frontend off the
host interface and let nginx front them — "remove port exposure (nginx will
handle it)". It did not work: Compose concatenates list-valued fields when
merging -f files, so `ports: []` contributed nothing and 8000:8000 and
3001:80 survived from compose.yml.

The effect was that an SSL deployment still answered on both ports over
plain HTTP, bypassing TLS, HSTS, the security headers and the rate limits —
confirmed against the live host from off-box, where the API returned its
health payload unencrypted on :8000 and the panel loaded on :3001.

Use the !reset tag so the inherited value is dropped rather than extended.
Verified in place: the merged config now publishes only 80 and 443, both
former ports refuse connections from outside, and the panel still serves
over HTTPS.
2026-08-11 21:05:42 +05:00
935c80427f fix(nginx): stop rate-limiting the frontend bundle into a blank page
Loading the panel over the domain produced a white page while the same
panel served directly from the frontend container rendered fine. The built
SPA requests 86 files on a cold load — one entry module plus 85 preloaded
chunks — and the server-level

    limit_req zone=general burst=20 nodelay;   # general = 10r/s

applies to those static assets too. Measured against the live site, 60 of
the 86 came back 503 and nginx logged "limiting requests ... by zone
general" for each. The entry module was among the casualties, so React
never booted and the document stayed empty.

Give static assets their own zone and restate the limit in a location that
matches them. The limit has to be restated rather than omitted: nginx
inherits limit_req from the enclosing level only when a location declares
none, and there is no `limit_req off`.

Both templates get the block, since the non-SSL config serves the same
bundle. Verified on the live deployment: 86/86 assets now return 200 and
the login page renders with no console errors.
2026-08-11 21:05:30 +05:00
74b7a9fe73 fix(nginx): raise the container file descriptor limit to match worker_connections
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
nginx.conf configures 2048 worker_connections, but Docker's default
RLIMIT_NOFILE of 1024 capped what the workers could actually open — and
since a proxied connection needs a descriptor both downstream and upstream,
the real ceiling was about a quarter of the configured one. nginx warned
about exactly this on every config test.

Raise nofile to 8192 for the nginx container. Verified in place: the limit
goes from 1024 to 8192 and `nginx -t` is now free of warnings.
2026-08-11 20:47:49 +05:00
5f20b87824 fix(nginx): drop two directives that only produced reload warnings
`listen 443 ssl http2` has been deprecated since nginx 1.25.1 in favour of
a separate `http2 on;` directive, and Let's Encrypt no longer publishes an
OCSP responder URL in its certificates, so `ssl_stapling` had nothing to
staple. Both warned on every reload — noise that now matters more, since
nginx reloads itself every 6h to pick up renewed certificates.

Reloads are clean afterwards and HTTP/2 is still negotiated.
2026-08-11 20:38:03 +05:00
c6a32255df fix(ssl): stop reporting a working certificate as a failed deployment
The SSL deployment aborted even when Let's Encrypt had issued a perfectly
valid certificate. obtain_ssl_certificate() confirmed success with

    [ -d "nginx/certbot/conf/live/${domain}" ]

but certbot runs as root inside its container and creates live/, archive/
and accounts/ with mode 0700. Whenever deploy.sh runs as a non-root user
that test fails with EACCES no matter what is on disk, so every SSL deploy
fell back to the HTTP-only config and printed "Failed to obtain SSL
certificate" right after certbot reported the certificate was fine.

Check the certificate from inside a container instead, and treat the files
on disk as the source of truth rather than certbot's exit code, so a
certificate that is present and not yet due for renewal counts as success.
Pass --keep-until-expiring to make that case explicit.

Also drop the recursive chmod on nginx/certbot: it could not touch the
root-owned files it was aimed at (producing the wall of "Operation not
permitted" noise) and would have exposed the private keys if it had.

Verify nginx actually came up after switching to the SSL config and roll
back to HTTP if it did not — a crash-looping nginx takes the whole panel
offline, which is worse than serving without HTTPS.

Reload nginx every 6h so renewed certificates are picked up: nginx reads
the certificate once at startup, so the renewal loop would otherwise keep
serving the old one until the container happened to restart.

Ignore nginx/certbot/ — it holds Let's Encrypt private keys and was
untracked but not ignored.
2026-08-11 20:28:58 +05:00
bddbf15652 feat(rbac): split setting:view into panel settings and a permission-free profile
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m22s
Deploy to Production / Trigger production update (push) Failing after 11s
setting:view used to gate the whole /settings page, which mixed panel-wide
configuration with a user's own account. Revoking it to keep a standard user
out of the panel settings also took away their profile, password, 2FA and SSH
keys, so the permission could not be used for what it names.

The account half now lives at /profile: its endpoints are self-scoped and only
require a session, so the page carries no permission at all and is reached from
the user menu (and Ctrl+K) rather than a sidebar group. /settings keeps the
panel-wide tabs behind setting:view, with saving gated on setting:update,
security on setting:manage and the SMTP/Telegram channels on is_admin, matching
what each endpoint checks.

The shipped "user" role no longer gets setting:view. Migration 42 drops it from
roles holding it without setting:update/manage, since those grants only existed
to reach the profile page; it runs once (marker in panel_settings) so a later
deliberate grant is not reverted on the next start.
2026-08-11 20:07:34 +05:00
991daddd63 fix(rbac): guard routes by permission and hide admin-only tabs
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Hiding a sidebar entry was the only frontend gate: /users, /logs and
/settings opened for anyone who typed the URL, and their pages rendered
every tab regardless of the caller (the backend then answered the tab's
requests with 403s).

Route elements are now wrapped in RequirePermission, gated on the same
permission the page API requires, so a hidden menu item can no longer be
reached by URL. Denied users land on the first page they can open, or on
an explanatory screen when nothing is reachable, so the redirect cannot
loop. Permission checking moves into lib/permissions.ts and canSeeNavItem
delegates to it, keeping menu and guard in sync.

Settings hides the panel tab without setting:update, the security tab
without setting:manage, the SMTP/Telegram cards from non-admins, and the
update controls behind setting:manage; profile, SSH keys and personal
notification preferences stay available to everyone. Users hides the
roles tab without role:view and the security tab without setting:manage.
2026-08-11 18:44:20 +05:00
c40611bedb feat(instances): tell users which quota is in the way before the wizard
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Quotas were only enforced by check_quota() on the backend, which answers 429
from the deploy endpoint — so a user walked all five wizard steps and learned
about the limit on the very last click. QuotaHint coloured the offending number
red but blocked nothing.

Split the two situations the UI never distinguished:

  * No headroom at all (used >= limit): no configuration would ever fit, so the
    wizard does not open. It is replaced by a message naming exactly which
    limits are used up, with their numbers.
  * Headroom left but the chosen configuration does not fit (used + requested >
    limit): the wizard opens, and from the config step onwards it names the
    metric and disables Next/Deploy until the resources are reduced.

The overrun check deliberately stays quiet until the config step. Gating every
step meant a default size that did not fit the remaining quota stranded the user
on the server picker, with no way to reach the fields and shrink it.

Neither block applies to admins: their own quota says nothing about an instance
they are creating on behalf of someone else, and blocking on it would lock an
admin with a quota out of the wizard entirely. The figures are still shown.

Also label the quota card as a projection. It renders used + the instance being
created, so a user with 3 of 4 instances saw "4 / 4" and reasonably read it as
already exhausted — which is what prompted this.

The arithmetic lives in features/instances/quota.ts as pure functions rather
than inline in JSX, so the two thresholds can be reasoned about on their own.
2026-08-11 18:21:51 +05:00
c4fda1b0bb feat(migrations): backfill the owner of instances created before the fix
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m20s
Deploy to Production / Trigger production update (push) Failing after 10s
Instances deployed while save_vm_instance() dropped the owner are still sitting
in the database with owner_id = NULL, so their users cannot see them. Recover
the creator from the two places it was recorded:

  1. deploy_tasks — user_id is a real foreign key stored when the deploy was
     queued. server_id is absent for LXC deploys, so a NULL there is treated as
     matching any server instead of discarding the row.
  2. audit_logs — vm_create / ct_create / lxc_create entries, which only carry a
     username and are matched by name.

Ownership decides who may see, stop and delete an instance, so a wrong guess is
worse than no owner at all: a row is updated only when every applicable piece of
evidence names the same existing user. VMIDs get reused, so conflicting evidence
is expected and those instances deliberately stay ownerless for an operator to
assign with assign_instances_owner.py. Existing owners, soft-deleted rows,
failed create events and logs from deleted users are all left alone, which also
makes the migration idempotent.

Note this recovers who created an instance, not who it was meant for: a VM an
admin created on behalf of a user is attributed to the admin, because the
intended owner was never persisted before the fix.

The attribution rule is a pure function (pick_backfill_owner) so it can be
tested directly. The migration's own statements are portable, so the tests run
it against SQLite with the two information_schema helpers stubbed out.
2026-08-11 17:28:59 +05:00
1152410860 fix(instances): assign the owner on create and inject the owner's SSH keys
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m30s
Deploy to Production / Trigger production update (push) Failing after 10s
Newly created VMs/LXCs ended up with owner_id = NULL, and a VM created by an
admin on behalf of a user received the admin's SSH key instead of the user's.

Ownership: save_vm_instance() only set owner_id when inserting a new row. The
row practically always existed by then — sync_vm_cache runs every 10s and
registers VMs it sees on Proxmox with owner_id = NULL while the clone is still
running, whereas the deploy saves the owner minutes later at 95% progress. So
every deploy landed in the update branch and silently dropped the owner. Set
owner_id there too, but only when it is passed explicitly: callers such as
save-before-delete do not know the owner and must not blank it out.

SSH keys: the profile key was taken from whoever pressed "Create" rather than
from the instance owner, and the LXC path injected no profile key at all. Add
build_deploy_ssh_keys(), which merges pasted keys, keys picked from the library
(same authorization rules as before) and the owner's profile key, deduplicated.
Both the template and LXC deploy endpoints now use it, so the key material is
resolved in one place instead of inside the deploy worker.

ISO VMs are left alone on purpose: they are blank VMs without cloud-init, so
there is nowhere to inject keys.

Covered by backend/tests/test_instance_ownership.py, including the sync race.
2026-08-11 17:19:19 +05:00
markmorado
e2d776bb6c fix(bootstrap): register the host at the correct /proxmox/api/servers path
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
The proxmox router is mounted under the /proxmox prefix in main.py, so the
real create-server endpoint is /proxmox/api/servers, not /api/servers as
the registration step assumed. The bare /api root only has /api/auth/*.

Verified end to end against a real Proxmox host: LXC provisioning, Docker
install inside it, deploy.sh --standalone build+run, admin login, local
root@pam token creation, and registration all completed successfully,
with the panel reporting the new node online.
2026-07-25 01:01:38 +05:00
markmorado
35220a1458 fix(bootstrap): tolerate apt update failures from the PVE enterprise repo
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
On an unsubscribed Proxmox host, apt's enterprise repo answers 401 and
apt-get update exits non-zero even though every other source succeeded,
aborting the whole bootstrap under set -e before git/curl were ever
installed. The subsequent apt-get install still fails loudly if a package
genuinely can't be found, so tolerating this specific failure is safe.

Found by running bootstrap.sh against a real Proxmox host.
2026-07-25 00:47:03 +05:00
markmorado
e81abf33df fix(nodes): stop showing wrong node uptime for multi-node clusters
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Server card used Math.max over all cluster nodes' uptime, so
rebooting one node still showed another node's higher uptime,
making it look like the reboot had no effect.
2026-07-25 00:44:56 +05:00
markmorado
f4f917c73e feat(install): add one-command bootstrap for regular hosts and PVE hosts
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Adds bootstrap.sh implementing Stage 3 of the roadmap: it detects whether
it runs on a plain Debian/Ubuntu host or directly on a Proxmox VE host.
On a PVE host it provisions a Debian 12 LXC, installs the panel inside it
via the existing deploy.sh, then creates a root@pam API token locally and
registers the host as the panel's first node — no Proxmox password is
ever transmitted.

Also removes the insecure pvemanager_secure_password fallback for
POSTGRES_PASSWORD in compose.yml, since every deploy path already
generates a real .env before the stack starts.
2026-07-25 00:44:08 +05:00
markmorado
b7c3a6ed35 feat(instances): ISO eject/mount with boot-order switch and hybrid reboot
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
- Images: show real ISO files already on the node storage (incl. those
  present before the node joined the panel) via existing node/isos endpoint
- ISO dialog: rename "detach" to "Eject", show current image inside the
  select (drop overflowing side text), add "boot from disk" checkbox
- Backend: on eject, put disk first in boot order; on mount, optional
  "boot from this ISO" puts CD-ROM first
- Reboot to apply the new boot order via hybrid restart: graceful ACPI
  shutdown with forceStop fallback, then start (fixes guest-ping timeout
  on freshly installed OS without QEMU Guest Agent)
- Update iso_next_steps hint to reflect the automated flow
2026-07-24 23:00:32 +05:00
markmorado
aa100b49c1 docs: release notes and version bump for v1.14.0
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
2026-07-24 07:00:46 +05:00
markmorado
29a5d4381d fix(instances): keep workspace-scoped filters in sync with active workspace
The per-column "Node" filter memoized its option list against TanStack's
Column object, which keeps the same identity across data updates, so the
checklist stayed frozen at whatever nodes were visible when the popover
first mounted instead of reflecting the current (workspace-scoped) rows.

Switching the active workspace also left the server/node/column filters
holding values from the previous workspace, which could filter the newly
scoped VM list down to nothing.
2026-07-24 07:00:41 +05:00
markmorado
dfe91964d9 docs: release notes and version bump for v1.13.0
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
2026-07-24 06:58:21 +05:00
markmorado
c526be3bb2 feat(appstore): add storage selection to golden template dialog
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Replace free-text storage inputs with dropdowns populated from the
server/node's actual storages (filtered by vztmpl/rootdir content),
matching the pattern used elsewhere in the app.
2026-07-24 06:52:30 +05:00
markmorado
3d63f4fb51 feat(scripts): add name search to scripts catalog
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
2026-07-24 06:42:43 +05:00
markmorado
d0b4c70c72 docs: release notes and version bump for v1.12.0
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
2026-07-24 06:39:24 +05:00
markmorado
9b493da7ad feat(cluster): import existing Proxmox cluster into the panel
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Add detect/import flow for nodes already clustered on the Proxmox side
(outside the panel). Matches nodelist entries from cluster/config/join
against known panel servers by IP/hostname and links their panel
records to the cluster_name group, without running any pvecm operation.
2026-07-24 06:35:34 +05:00
markmorado
b804a9bec5 docs: release notes and version bump for v1.11.0
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
2026-07-24 06:30:51 +05:00
markmorado
99d8ce750d feat(images): support uploading local ISO/vztmpl files to node storage
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Adds a local file upload path alongside the existing URL download flow:
a new /images/upload endpoint streams the file to a temp path and hands
it to a background worker that proxies it into Proxmox storage via the
upload API (requests-toolbelt handles the large multipart stream). Also
adds /images/resolve-filename to recover the real filename (Content-
Disposition) for URL-based downloads with opaque paths.

Raise nginx client_max_body_size and timeouts on both the frontend proxy
and the standalone nginx configs so multi-GB ISOs don't get cut off.
2026-07-24 06:17:46 +05:00
markmorado
8b4c8c3a30 fix(realtime): sync instance/workspace list state without manual refresh
- Invalidate workspace list cache after assigning servers/users so
  server/user counts on workspace cards update immediately.
- Invalidate node/VM caches after workspace server/user assignment so
  the Nodes page reflects visibility changes right away.
- Soft-delete the cached VM/LXC row and broadcast vm_deleted the moment
  a VM/container is destroyed, instead of waiting for the 10s
  background sync_vm_cache tick.
- Invalidate the VM list on the frontend when a deploy task completes,
  since the backend already persists the new instance to cache
  synchronously before marking the task done.
2026-07-24 05:39:23 +05:00
markmorado
e10f9a2a03 refactor(servers): unify connection status badges
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Extract ServerStatusBadge so every server list renders the auth-error
state identically instead of repeating the online/offline ternary, and
expose last_error_kind from the cluster topology and user-assignment
endpoints, which build their payloads by hand and so did not inherit it
from the server schema.
2026-07-24 05:04:45 +05:00
markmorado
ebc692a324 feat(instances): create blank VMs for ISO installs and download ISOs
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Add a third instance kind to the create wizard: a blank KVM VM with an
empty disk, the installation ISO mounted on ide2 and put first in the
boot order, so the guest boots straight into the installer over noVNC.
Picking a Windows guest presets OVMF, a SATA disk and an e1000 NIC,
which the installer sees without virtio drivers loaded.

Also teach the image downloader content=iso, so ISOs can be fetched
onto a node from the panel: by URL from the images page or from a saved
mirror (mirrors now accept kind=iso), and straight from the ISO step of
the wizard.

The bundled catalog stays qcow2-only on purpose — install ISOs have no
stable "latest" URLs, so hardcoded links would rot on every point release.
2026-07-24 05:00:51 +05:00
markmorado
6a4da4f80b docs: revise the one-command install stage
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
deploy.sh already covers most of the installer (OS detection, Docker setup,
random secret generation, nginx, TLS, image build, pve CLI), so the stage is
about publishing a thin bootstrap rather than writing one from scratch.

Records the decisions taken: the bootstrap detects its environment and
creates an LXC when run on a PVE host, the repository is public, and images
keep being built locally. On a PVE host the node can register itself without
a password via pvesh, reusing the token provisioning from stage 1.
2026-07-24 04:43:07 +05:00
markmorado
075b8cdaaa feat(servers): distinguish rejected credentials from unreachable nodes
An offline server used to report a single "Failed to connect" state, but
"the node does not answer" and "the node answered 401" are fixed in
completely different ways — the first is power or network, the second is a
stale token, which is what happens when a node joins a cluster and its
user.cfg and priv/token.cfg are replaced by the cluster ones.

- add ProxmoxClient.check_connection() returning (ok, error_kind, error);
  token auth surfaces 401 as ResourceException rather than
  AuthenticationError, since proxmoxer validates the token on first use
- store the reason in proxmox_servers.last_error_kind (migration 40) and
  expose it through the server response
- surface an auth_error state in the UI with a hint explaining the usual
  cause

Storage listings now also report per-node availability: the cluster config
declares a storage for the whole cluster but says nothing about whether it
actually came up on a given node, so a missing volume group or pool stayed
invisible. Availability comes from a single /cluster/resources call.
2026-07-24 04:42:59 +05:00
markmorado
db7411b1bf docs: add development roadmap
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Records the six agreed directions with their status, the files each one
touches, the steps involved and the decisions already made, so the work can
be picked up without prior context.
2026-07-24 04:03:37 +05:00
markmorado
65c61e7886 feat(servers): auto-provision API tokens and add node metric graphs
Servers can now be connected with a login and password: the panel
authenticates once, creates its own API token on the node (privsep=0) and
uses it from then on. The token can be revoked from the Proxmox UI, cannot
be used for SSH or web login, and appears in the PVE audit log under its
own name. The password stays stored (encrypted) because ProxmoxClient needs
it for SSH fallbacks such as pvecm delnode.

- add proxmox/token_provisioning.py: token creation with free-name
  selection and revoke-on-failure so a failed registration does not leave
  an orphaned token whose secret was never persisted
- POST /api/servers accepts auto_create_token
- add POST /api/servers/{id}/provision-token to migrate servers that are
  still on password auth
- store api_token_value as EncryptedString; it was kept in plaintext while
  the password next to it was already encrypted
- drop the unused ProxmoxServerBase schema, a stale copy of the create
  schema whose validator rejected the new auth mode

Node detail pages gain a Graphs tab (CPU, IO delay, memory, load average,
network, swap, root filesystem) built on the existing node rrddata
endpoint. The chart card is extracted from the instance graphs tab so both
views share it, and X axis ticks now switch to dates on multi-day ranges.
2026-07-24 04:03:30 +05:00
markmorado
b66424de12 chore: ignore .claude directory 2026-07-24 04:02:54 +05:00
markmorado
07af758f9a fix(nodes): show a loading state for the instances list
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
The overview tab rendered "no data" while the VM list was still loading, so a
server with instances looked empty until the request came back. Gate the empty
state on isLoading and show skeleton rows instead.

Also add the missing nodes.instances_on_server key to both locales — the raw
key was rendered in place of the heading.
2026-07-24 03:18:22 +05:00
markmorado
ff1396142b fix(api): scope server listings to workspace, stop serialising Proxmox calls
/api/cluster/topology and /api/resources/all read every ProxmoxServer row
regardless of the caller access or the X-Active-Workspace header, so the
Cluster page listed all servers and dashboard aggregates counted the whole
panel instead of the active workspace. Extract the access + workspace filter
out of /api/servers into get_visible_server_ids() and apply it in all three.

Live metrics were also gathered server by server with the cache lock held
across the network calls, so a single unreachable cluster stalled the request
(21-31s in the logs) and concurrent requests queued behind it. Fetch in
parallel, skip servers already known offline, drop the redundant
is_connected() probe, and hold the lock only around the cache dict.

/api/resources/all now orders by server id: without it Postgres returned heap
order, which shifts whenever update_status rewrites a row, so the server list
reshuffled between requests.
2026-07-24 03:18:14 +05:00
markmorado
35a8a11bb5 docs: add commit message language and no-co-author rules 2026-07-23 23:23:45 +05:00
markmorado
4cdbed54e4 style(theme): add blue tint to dark theme surfaces
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Shift dark-theme background, card, popover, sidebar and border/input
tokens from neutral hue 286 (near-zero chroma) to a blue-graphite
hue 258 with higher chroma, so the panel reads as midnight-blue
instead of flat black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 23:21:05 +05:00
markmorado
fca14828dc docs(wiki): expand RBAC guide and add full Russian WIKI.ru.md
Rewrite the RBAC/Security section with a detailed model: permission
format, engine evaluation order (admin bypass, wildcard, manage-implies),
requires dependencies, full permission catalogue, per-page permission
map, built-in role breakdown, and the ownership-based user isolation
(vm:manage, not vm:view, grants the global view). Fix stale references
(version 1.10.2, Networks needs server:manage, SSH keys user:manage).

Add WIKI.ru.md as a complete Russian translation mirroring WIKI.md,
including the expanded RBAC section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:58:18 +05:00
markmorado
e67f6b7249 fix(update): recover from stalled panel self-update instead of hanging at 80%
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
The panel update status lived only in-memory and was cleared solely by the
host watchdog restarting the container. If the rebuild failed mid-way (e.g. the
build OOMs the host when no swap is configured), the container never restarted
and the UI stayed at "restarting - 80%" forever.

- update_service: get_update_status() now self-heals — after UPDATE_TIMEOUT_SECONDS
  (default 900s) of "is_updating" it transitions to a terminal "failed" state with
  a clear error pointing at the host update service / logs.
- use-settings: poll update status while the server reports is_updating (not only
  right after the click in the same tab), so reloading a stuck page keeps refreshing.
- SettingsPage: banner now renders the "failed" state with the error message.
- update_host.sh: build images sequentially (app, then frontend) to lower peak
  memory and avoid the OOM that killed the build mid-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:57:41 +05:00
markmorado
ceec83d2fb docs: release notes and version bump for v1.10.2
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:22:10 +05:00
markmorado
06b61dae65 fix(templates): show clear message after auto-import instead of "undefined"
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Frontend read res.count but the auto-import endpoint returns imported_count,
so the toast showed "Импортировано: undefined". Read imported_count and show
a distinct message when no new templates were imported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:15:02 +05:00
markmorado
3502a72a13 docs(claude): require English commit messages
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
2026-07-23 21:59:31 +05:00
markmorado
d0824df0f5 docs: release notes and version bump for v1.10.0
Document the command palette, reorganized instance menu/tabs, global
minimized-operations tray, owner-scoped live Proxmox resources, and the
React Query cache clear on login/logout; bump VERSION and README badges.
2026-07-23 21:59:24 +05:00
markmorado
0b0b11c689 feat(scripts): run script executions in background and poll for completion
Community scripts (build.func) install packages like Java/OpenSearch and
can take minutes, well past the nginx/proxy HTTP timeout. execute_script
now returns immediately with status "running" and finishes the job on a
background thread with its own DB session; the frontend polls
/api/scripts/executions/{id} every 3s while running.

Also set TERM=xterm for script execution over SSH — community scripts
call `clear` and print colored output, which fails with "TERM
environment variable not set" when run without a PTY.
2026-07-23 21:59:18 +05:00
markmorado
761bd7bca8 fix(workspaces): grant server access via workspace membership alone
Non-privileged users previously needed a server to be both directly
assigned AND inside one of their workspaces (intersection) to see or use
it. Adding a user to a workspace should be sufficient on its own to let
them create instances on that workspace's servers, so the two access
paths are now unioned instead of intersected.
2026-07-23 21:59:09 +05:00
markmorado
203ae8bdd6 fix(workspaces): stop filtering server list by active workspace in assignment dialog
The workspace edit dialog reused useServers(), which always sends the
X-Active-Workspace header, so admins assigning servers to a workspace
only ever saw servers already in the currently active workspace instead
of the full list. apiClient now lets a caller override the header, and
useServers(allWorkspaces) requests '0' (admin "show all") for that dialog.
2026-07-23 21:59:02 +05:00
markmorado
ef5b40b2f7 fix(nodes): не давать пустым полям учётных данных затирать токен/пароль сервера
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
При редактировании ноды (например, при смене имени) форма отправляла
пустые api_token_name/api_token_value, т.к. секреты не возвращаются
с бэкенда. Бэкенд принимал их как явно заданные и затирал реальные
credentials, из-за чего немедленная проверка подключения падала и
сервер помечался offline. Теперь пустые значения при редактировании
трактуются как «не менять» — и на фронтенде, и на бэкенде.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:57:27 +05:00
markmorado
d3b834cbe1 feat(auth): сбрасывать активную workspace при имперсонации и возврате
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
При смене токена (вход под пользователем / возврат к админу) сбрасываем
выбранную рабочую область через setActiveWorkspace(null): выбор
предыдущей личности передавался в хедере X-Active-Workspace и мог не
принадлежать новой. Дополняет уже существующий сброс кэша React Query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 05:37:23 +05:00
markmorado
43336c02c7 feat(auth): имперсонация — вход админа под пользователем и возврат обратно
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Backend:
- POST /api/auth/impersonate/{user_id} — только для админа, выдаёт JWT
  с клеймом impersonator; запрещает вход под собой, отключённым,
  другим админом и вложенную имперсонацию
- POST /api/auth/stop-impersonate — по клейму impersonator завершает
  сессию имперсонации и перевыпускает токен исходного админа
- /api/auth/me отдаёт поле impersonator для баннера в SPA
- аудит-лог: impersonate_start / impersonate_stop

Frontend:
- auth-store: действия impersonate/stopImpersonate со сбросом кэша
  React Query при смене токена
- UsersPage: кнопка "войти под пользователем" (только админу,
  скрыта для себя/админов/неактивных и во время имперсонации)
- AppLayout: баннер имперсонации с кнопкой "вернуться к администратору"
- локали ru/en

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 05:33:04 +05:00
markmorado
982522b8b5 fix(auth): очищать кэш React Query при login/logout
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
При входе под другим пользователем в той же вкладке React Query отдавал
закэшированные данные предыдущего пользователя (список инстансов и др.) до
жёсткой перезагрузки, из-за staleTime и отсутствия сброса кэша при смене сессии.

- queryClient вынесен из App.tsx в lib/query-client.ts (доступен вне React)
- auth-store вызывает queryClient.clear() при успешном login и при logout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 05:22:14 +05:00
markmorado
42c6acd52e fix(instances): изоляция по владельцу для живых ресурсов Proxmox
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Список инстансов (/api/virtual-machines) уже фильтровался по owner_id, но
эндпоинты живых ресурсов Proxmox — /api/resources/all (дашборд) и
/api/{server_id}/resources (страница ноды/сервера) — отдавали все VM/LXC без
фильтра. Обычный пользователь (proxmox.view) через страницу ноды видел чужие
инстансы.

- _helpers: get_owned_vmids() — VMID, которыми владеет пользователь
- servers: get_server_resources / get_all_resources фильтруют VM/LXC по
  владельцу для не-привилегированных (сверка vmid с кэшем vm_instances);
  админы и роли с vm:manage видят всё
- assign_instances_owner.py: скрипт массового назначения владельца
  существующим инстансам (owner_id синхронизированных из Proxmox = NULL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:56:17 +05:00
markmorado
25899866bc feat(appstore): глобальный трей свёрнутых операций, переживающий навигацию и перезагрузку
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 9s
Свёрнутые долгие операции (установка приложения, сборка золотого шаблона)
теперь живут в глобальном zustand-сторе (persist в localStorage) и
рендерятся треем MinimizedOpsTray в AppLayout, а не самим диалогом:

- плашка переживает навигацию между страницами и перезагрузку вкладки;
  прогресс опрашивает сам трей (REST, 3с) независимо от диалога
- клик по плашке возвращает на страницу операции и разворачивает диалог:
  установка — resume-режим InstallWizard (resumeInstall в navigation
  state), шаблон — openGolden с предвыбранным сервером
- «Свернуть» добавляет операцию в трей и закрывает диалог; крестик на
  плашке только убирает её (операция продолжается на сервере)
- плашки несуществующих операций (404 после перезагрузки) трей убирает сам

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:13:42 +05:00
markmorado
4621ffe88a fix(appstore): data files, надёжность установки/удаления и сворачиваемые диалоги
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
Доставка файлов приложения из каталога (data_files) до compose up,
APP_PASSWORD/default_credentials для Umbrel, сворачивание диалогов
долгих операций (MinimizableDialog), фильтрация pull-шума в ошибках.

Исправления багов:
- teardown: отсутствующий в Proxmox CT считается удалённым — orphaned-приложение
  теперь можно удалить из БД
- install: сдвиг VMID мимо занятых записей (nextid не резервирует номер,
  retry мог подхватить чужой контейнер)
- retry: параметры установки сохраняются в installed_apps.install_params
  (cores/memory/disk/storage/bridge/ip_config/port_explicit, миграция JSONB)
  вместо жёстких дефолтов
- reconcile: недоступный сервер больше не помечает все приложения orphaned
- update: реально опубликованный порт фиксируется в БД (url/health)
- .env: значения с '#' кавычатся (dotenv-парсер обрезал их как inline-комментарий)
- порт 80, выбранный явно в мастере, публикуется как есть
- useCatalog: available_only включён в queryKey (коллизия кэша)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:52:14 +05:00
markmorado
f22b24d86a fix(appstore): touch file bind-mounts instead of mkdir, breaking .env mounts
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
docker compose up failed for apps that bind-mount a single file (e.g.
AnythingLLM's .env) because _prepare_bind_mounts blindly mkdir -p'd every
bind-mount source, turning the file's path into a directory. Docker then
refused to mount a file onto that directory ("not a directory: ... mount
a directory onto a file"). File-looking sources (by name) now get touch'd
instead, with self-healing rm -rf for directories left over from prior
failed installs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 05:19:19 +05:00
markmorado
6a79dd2048 fix(appstore): DNS, per-server golden template, bind-mount perms, delete race
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m1s
Deploy to Production / Trigger production update (push) Successful in 8s
App Store install/update pipeline reliability fixes:
- explicit nameserver (APPSTORE_NAMESERVER) on App Store LXCs and the golden
  template build CT, instead of inheriting a node resolver that may be
  unreachable from the container network (e.g. Tailscale-only DNS)
- golden template storage/resolution moved from one global setting to
  per-server_id (migration 37: app_operations.server_id) — building it on one
  Proxmox server no longer overwrites the template configured for another
- golden template automated build fixed: vzdump export step no longer fails
  on "unable to parse directory volume name 'vztmpl/probe'"
- bind-mount host directories are pre-created with permissive ownership
  before `docker compose up`, fixing Permission denied for non-root
  containers (e.g. MariaDB) writing to Docker-auto-created root-owned dirs
- delete/teardown now waits for Proxmox to actually confirm the destroy task
  finished (was silently proceeding after a short timeout, letting a new
  install reuse the VMID while the node was still wiping the old disk)
- pct create retries a few times on transient "got lock" config errors
- failed pipeline steps now report the tail of command output (4000 chars)
  instead of the head, so the real error isn't buried under pull/progress noise
- install error box in the UI wraps long text and scrolls instead of
  stretching the dialog

Also carries forward in-progress script catalog / instances / RBAC changes
that were already uncommitted in the working tree before this session.

Bump to v1.9.1, update WIKI.md/docs/CHANGELOG accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 04:49:40 +05:00
markmorado
b8536ac2e8 feat(instances): command palette, unified nav, расширенное меню инстанса и правки табов
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
- Командная палитра (Ctrl+K) с общим реестром nav-items и его store
- Новые общие UI-примитивы: alert, breadcrumb, checkbox, progress, switch, textarea
- Меню инстанса: добавлены «Параметры», «Мигрировать», «Изменить ресурсы»,
  «Создать резервную копию» (новый BackupDialog) и «Удалить»; исправлено
  закрытие меню при живом обновлении списка (стабильные columns + getRowId)
- Вкладки инстанса: «Вычислительные ресурсы», «Опции CPU», «Управление
  дисками» и «Изменение размера диска» перенесены с «Настройки» на
  «Оборудование» для VM (для LXC, у которых нет вкладки «Оборудование»,
  ресурсы и ресайз диска остаются в «Настройках»); общие карточки вынесены
  в ResourceCards.tsx
- Локализация: новые ключи ru/en для добавленных пунктов меню и бэкапа

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 00:58:45 +05:00
markmorado
8ca33681ca feat(scripts): каталог bash-скриптов для нод и гостей (VM/LXC)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 59s
Deploy to Production / Trigger production update (push) Successful in 8s
Добавлена библиотека скриптов по образцу VMmanager: свои скрипты (CRUD в UI)
и синхронизация из git-репозитория, параметризация через env-переменные
(без конкатенации в shell), выполнение на нодах Proxmox (SSH), в LXC (pct
exec) и в VM (QEMU guest agent, переиспользует существующий execute_script).
Журнал запусков встроен в общий список задач /api/all-tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 04:35:43 +05:00
markmorado
f2ee09ab5a polish(ui): пульсация статус-точки для online, моно-цифры в бейджах, fade-up анимация
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 04:35:29 +05:00
markmorado
db2f408d57 fix(core): пункты 5-10 аудита — UPID-синк, порт, бэкап all, IPAM, креды
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 8s
5. UPID-синк: транзитная ошибка опроса статуса больше не помечает живую
   задачу failed — get_task_status(raise_on_error=True) + счётчик из 3
   последовательных неудач; в exit_status пишется реальная причина.
6. Дашборд и бэкапы для серверов на порту != 8006: metrics_broadcaster и
   backup_scheduler строят клиент через ProxmoxClient.from_server (учёт
   порта и token/password) вместо ручной сборки по ip_address.
7. Бэкап "все VM": create_backup при vmid in (None, 0, "all") передаёт
   vzdump параметр all=1 — раньше уходил vmid=0 и PVE отвергал задание.
8. IPAM: get_next_available_ip собирает занятые IP глобально (как проверяет
   allocate_ip), а не только по pool_id — авто-выдача больше не предлагает
   занятый адрес и не срывает создание инстанса.
9. IPAM: fallback в release_ip_by_vmid ищет только записи без
   proxmox_server_id — удаление VM не освобождает IP одноимённого vmid
   на другом сервере.
10. _get_proxmox_client: сервер без валидных кредов теперь возвращает
    понятную 503-ошибку вместо молчаливо пустых списков; перечисление
    серверов в метриках защищено per-server try/except.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:06:28 +05:00
markmorado
0efa5968ad fix(core): четыре критических бага — маршруты, очередь задач, логин, shutdown
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
1. vms.py: catch-all маршруты /vm/{vmid}/{action} и /container/{vmid}/{action}
   перенесены в конец файла — раньше они перехватывали POST /unlock,
   /cloud-init и /exec, и эти операции всегда падали с 400 "Invalid action".
2. task_queue_service: зависшие после рестарта running-задачи помечаются
   failed при старте — иначе очередь bulk-операций блокировалась навсегда.
3. api-client.ts: 401 от /api/auth/login больше не вызывает перезагрузку
   страницы — пользователь видит сообщение "неверный логин/пароль".
4. task_queue_service: bulk shutdown теперь вызывает shutdown_vm/
   shutdown_container (graceful) вместо жёсткого stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:58:40 +05:00
markmorado
e62626c79b fix(nodes): детерминированная сортировка списка серверов
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m0s
Deploy to Production / Trigger production update (push) Successful in 7s
Список серверов терял порядок при фоновом рефетче (каждые 30с),
т.к. запрос с WHERE id IN (...) не гарантирует стабильный порядок строк.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 00:40:17 +05:00
markmorado
3424823793 feat(cluster): режим eject "только панель" для нод, не входящих в кластер
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
При провале join нода остаётся стейл-записью с cluster_name в панели, хотя
в Proxmox она standalone. Обычный eject пытается pvecm delnode через SSH и
падает ("SSH connection failed"), т.к. нода не член кластера и/или узел
недоступен по SSH.

- eject-эндпоинт: новый параметр panel_only — отвязывает панельную запись
  (cluster_name=None) без обращения к Proxmox/SSH.
- UI: чекбокс "Только отвязать запись в панели" в диалоге исключения,
  блокирует поле пароля при выборе.
- Локали ru/en.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:36:55 +05:00
markmorado
c88cdb4e26 fix(cluster): join по приватному адресу corosync + подтверждение членства
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 7s
Проблема: присоединяемая нода подключалась к кластерной по её адресу из
панели (часто публичный FQDN), из-за чего join падал с
"Can't connect to <fqdn>:8006 (Connection timed out)" при выделенной
corosync-сети. Плюс панель помечала ноду как присоединённую сразу после
старта задачи, показывая ложное членство даже при провале join.

- join: новый опциональный параметр cluster_host — приватный IP кластерной
  ноды в corosync-сети; используется как hostname для API-подключения и
  для выбора fingerprint из nodelist. Фолбэк — ip_address из панели.
- join: cluster_name пишется в БД только после подтверждения, что нода
  реально появилась среди нод кластерной ноды (опрос до ~40с, укладываемся
  в nginx proxy_read_timeout 60с). Иначе — confirmed=false и предупреждение.
- UI: поле "Адрес кластерной ноды для подключения" в шаге 3 мастера,
  обработка неподтверждённого join (toast.warning, окно не закрывается).
- Локали ru/en.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:28:39 +05:00
markmorado
3b10e903a4 fix(cluster): корректное извлечение fingerprint из nodelist при join
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 8s
GET /cluster/config/join не возвращает fingerprint на верхнем уровне —
он лежит в nodelist[].pve_fp. Добавлен хелпер _extract_fingerprint,
который выбирает pve_fp целевой ноды (по IP), с фолбэком на
preferred_node и первую ноду. Устраняет ошибку 502
"Не удалось получить fingerprint кластера" при присоединении нод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:07:53 +05:00
markmorado
0d056a89a5 feat(nodes): TLS-сертификаты ноды и массовая миграция инстансов
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
- Сертификаты: методы клиента + endpoints GET/POST/DELETE
  /nodes/{node}/certificates, хуки и вкладка «Сертификаты» в
  NodeAdminManager (просмотр, загрузка PEM cert+key, удаление кастомного,
  бейдж срока действия)
- Bulk migrate: тип bulk_migrate в очереди задач с WS-прогрессом,
  target_node/online в BulkOperationItem, диалог выбора целевой ноды и
  кнопка в тулбаре инстансов (в пределах одного сервера/кластера)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:48:42 +05:00
markmorado
afde2bf569 feat(nodes): управление нодой как в Proxmox — питание, консоль, журнал/задачи/система
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 7s
Ядро:
- Reboot/Shutdown ноды (POST /nodes/{node}/status) под новым правом node:power
  с подтверждением в UI; меню действий на карточке ноды + пункт «Консоль (Shell)».

Доп. вкладки в «Администрировании ноды»:
- Журнал (syslog ноды), История задач (tasks + лог задачи),
  Система (DNS / /etc/hosts / часовой пояс).

Бэкенд: методы node_power_action/get_node_syslog/get_node_tasks/get_node_task_log/
get_node_task_status/DNS/hosts/time в NodeAdminMixin + эндпоинты в node_admin.py.
RBAC: право node:power (admin + backfill server:manage).

Проверено: py_compile, tsc --noEmit, vite build; live — маршруты в openapi,
403 без токена, node:power засеян роли admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:19:07 +05:00
markmorado
629357a0ee feat(appstore): автосборка золотого LXC-шаблона из UI (Workstream B)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
Автоматизирует ручную процедуру docs/golden-template.md: pveam download
базового Debian → pct create временного CT → установка Docker/Compose →
каталоги/сеть/logrotate → (опц.) проверка nginx → vzdump в vztmpl → удаление
временного CT. При сбое недособранный CT очищается.

- backend/app/appstore/golden_template.py — сборщик поверх SSH-инфраструктуры
  пайплайна (pct/pvesm/pveam/vzdump); идемпотентность по существующему vztmpl,
  версия базового шаблона берётся из pveam (не хардкодится)
- appstore_engine: prepare_golden_template/_run_golden_template (фон, журнал
  AppOperation + WS), golden_template_status; собранный volid сохраняется в
  PanelSettings и предпочитается env при установке (_resolve_golden_template)
- API: POST /api/appstore/golden-template, GET .../golden-template/status
  (app:manage / app:view)
- Frontend: GoldenTemplateDialog (выбор сервера/ноды/хранилищ, force, живой
  прогресс опросом статуса) + admin-кнопка «Золотой шаблон» на странице App
  Store; хуки useGoldenTemplateStatus/useBuildGoldenTemplate; локали RU/EN

Проверено: роуты зарегистрированы, golden_template_status и приоритет
_resolve_golden_template (override→saved→env) корректны, tsc 0 ошибок.
Полный e2e-прогон сборки на реальной ноде — по запросу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:53:47 +05:00
markmorado
aebb726231 perf(appstore): быстрый синк Umbrel — иконки с CDN параллельно + стартовый синк
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
A1: иконки Umbrel тянутся пофайлово с GitHub Pages CDN в пуле из 16 потоков
(<app-id>/icon.svg) вместо тяжёлого tarball галереи со скриншотами. Синк
Umbrel: ~7.5 мин → ~13-17с, 379/379 иконок. Настройка UMBREL_APPSTORE_GALLERY_CDN
заменяет UMBREL_APPSTORE_GALLERY_REPO. 404/сетевой сбой отдельного запроса
просто пропускает иконку, не роняя синк.

A2: на свежем деплое каталог пуст, а IntervalTrigger впервые сработает лишь
через сутки. Теперь при пустом каталоге планировщик ставит разовый синк
немедленно (trigger=date) — каталог наполняется на старте без ручной кнопки.

Проверено на стенде: стартовый синк наполнил каталог 644 (runtipi 265 +
umbrel 379) за 16.5с; эндпоинт логотипа отдаёт 200 image/svg+xml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:40:39 +05:00
markmorado
ebc1c8429e docs: релизы v1.8.1 (Umbrel в App Store) и v1.9.0 (IPAM-привязка, FQDN нод)
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
- VERSION → 1.9.0
- CHANGELOG: запись v1.8.1 (Umbrel как второй источник каталога, адаптер
  compose, иконки из галереи, мультиисточниковый синк, фильтр/бейдж источника,
  кастомный порт установки, мгновенная чистка инстанса) и v1.9.0 (привязка
  IPAM-сетей к серверу/ноде и области с автовыбором в визардах, приём FQDN в
  поле хоста ноды)
- README (en/ru): App Store — мультиисточниковый каталог (Runtipi + Umbrel)
- WIKI: источники, env-настройки Umbrel, иконки из галереи, фильтр/бейдж,
  параметр source в REST

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:32:44 +05:00
markmorado
4c164bec72 chore: убрать план из репозитория и игнорировать docs/plans/
Планы разработки не хранятся в репозитории — только пользовательская
документация. Файл сохранён у автора отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:28:36 +05:00
markmorado
7e75dc7e28 docs(appstore): дорожная карта — плагин-каркас, золотой шаблон, оптимизация иконок
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s
Самодостаточный план для выполнения в отдельной сессии: контекст проекта и
конвенции, текущее состояние (Umbrel-мультиисточник), три workstream'а
(оптимизация каталога/иконок, автосоздание золотого LXC-шаблона, каркас
управляемых плагинов) с пошаговыми инструкциями и критериями приёмки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:26:17 +05:00
markmorado
633f1a424c feat(appstore): кастомный порт при установке + мгновенная чистка инстанса
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 7s
App Store:
- поле кастомного порта в мастере установки (переопределяет порт каталога)
- валидация диапазона порта 1..65535 (Pydantic Field + EngineError)
- APP_PORT в окружении синхронизируется с выбранным портом
- при удалении приложения инстанс сразу помечается deleted в кэше
  vm_instances (soft_delete_vm_instance), не дожидаясь фонового воркера

Также включены правки IPAM (привязка сетей к рабочей области, UI
WorkspaceSelect) и синхронизации каталога App Store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:20:28 +05:00
markmorado
bbaae7c2b1 fix(nodes): принимать доменное имя/FQDN в поле хоста сервера Proxmox
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Поле ip_address валидировалось строго как IP, из-за чего подключение ноды по домену (node1.tzim.cloud) падало с 'Invalid IP address format'. Добавлен хелпер validate_host: принимает IP (v4/v6) или корректный hostname/FQDN. IPAM-валидаторы по-прежнему требуют строгий IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 01:20:06 +05:00
markmorado
a6eeae3dd7 feat(ipam): привязка сетей к серверу/ноде и автовыбор сети в визардах
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
- network-scope: scopedNetworks/autoPickNetworkId — фильтрация IPAM-сетей по
  выбранным серверу/ноде и детерминированный автовыбор сети для выдачи IP
- ServerNodeSelect: пара селектов «Сервер + Нода» для привязки сети
- Мастер установки App Store и CreateInstanceWizard: список сетей ограничен
  релевантными, при смене сервера/ноды сеть подбирается автоматически
- IPAM: страницы сетей учитывают привязку к ноде
- Исправлены типы обработчиков Select (onValueChange отдаёт string|null)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:09:17 +05:00
markmorado
23d3f95f54 fix(appstore): выбор хранилища в мастере установки + видимость реальной ошибки
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
- Установка: поле «Хранилище» стало выпадающим списком доступных rootdir-хранилищ
  ноды (useLXCStorages) с автовыбором валидного вместо ручного ввода
- Диагностика сбоя: увеличен захват вывода упавшей команды (400 → 2000 символов)
  и добавлен 2>&1 к docker compose pull/up — реальная причина (напр. отсутствие
  тега образа) больше не обрезается за строками warning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:28:39 +05:00
markmorado
6d5df8caa7 fix(appstore): устранить зависание/провал установки (compose network + SSH-таймаут)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 8s
- pipeline: _normalize_compose() объявляет упомянутые в сервисах сети
  (tipi_main_network) как external в top-level — иначе docker compose up
  падал с "refers to undefined network: invalid compose project"
- pipeline: LOCAL_DOMAIN=tipi.local в .env (стандартная переменная Runtipi)
- pipeline: per-command таймауты (_CMD_TIMEOUT/_DOCKER_TIMEOUT), при таймауте
  установка падает с ошибкой, а не зависает на 75%
- ssh_client: execute() ждёт готовности exit-status с жёстким дедлайном
  вместо бесконечного recv_exit_status() (ловушка paramiko)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:15:33 +05:00
markmorado
c87a13953e feat(appstore): показывать рабочий порт приложения в каталоге и на карточке
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Порт (CatalogApp.port) уже был в API и типах, но не выводился в UI. Добавлен вывод в шапку страницы приложения и бейджем в карточке каталога (ключ appstore.port уже в локалях).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:52:08 +05:00
markmorado
e497ae541f fix(appstore): резервный REST-опрос прогресса установки (не зависать при потере WS)
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Мастер установки получал прогресс только по WebSocket; при потерянном
событии (или долгом docker compose up с тяжёлыми образами) UI замирал на
последнем шаге. Добавлен поллинг /operations раз в 3с как фолбэк.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:50:31 +05:00
markmorado
06c4ec8e94 fix(appstore): заменить нативные confirm() в «Мои приложения» на стилизованный ConfirmDialog
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s
Удаление/обновление/откат приложения использовали window.confirm() — не в стиле панели.
Переведено на общий useConfirm (Promise API), удаление — destructive-вариант.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:36:39 +05:00
markmorado
ab5b13a8b8 revert(auth): удалить неиспользуемый AuthPattern
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s
Декоративный SVG-паттерн так и не подключён к странице входа —
убираем висячий компонент.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:31:31 +05:00
markmorado
08d4396d2f feat(auth): декоративный SVG-паттерн для страницы входа
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Компонент AuthPattern — бесшовный геометрический паттерн (SVG <pattern>,
currentColor) для левой панели логина. Пока не подключён к LoginPage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:24:02 +05:00
markmorado
45e998cb3e fix(appstore): убрать реферер и прятать битые картинки в markdown
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s
Скриншоты из сторонних README на CDN с hotlink-защитой (cloudinary)
обрывали запрос по Referer (NS_BINDING_ABORTED) и резались браузером
(OpaqueResponseBlocking). Снимаем реферер, мягко скрываем не
загрузившиеся изображения.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:21:46 +05:00
markmorado
9bfb055f69 feat(appstore): показывать операции App Store в «Задачах» + IP из IPAM при установке
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 55s
Deploy to Production / Trigger production update (push) Successful in 8s
- AppOperation.to_task_dict(): операции install/update/delete/… приведены к
  общему формату и включены в /api/all-tasks, active-count и clear-completed
- Установка приложения: опциональный выбор сети/пула IPAM — статический IP
  вместо DHCP (net0 ip=<addr>/<mask>,gw=…), освобождение адреса при удалении
- Мастер установки: селекторы «Сеть IPAM»/«Пул IPAM» (ru/en), по умолчанию DHCP

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:20:33 +05:00
markmorado
e453f49a6a fix(appstore): удалять запись installed_apps при удалении инстанса со страницы инстансов
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 55s
Deploy to Production / Trigger production update (push) Successful in 7s
При удалении LXC/VM через delete_vm/delete_container чистим таблицу installed_apps
(по server_id+vmid), иначе приложение из каталога оставалось «хвостом» в «Мои приложения».
Шлём WS-эвент appstore_app_deleted для live-обновления списка у владельца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:18:40 +05:00
markmorado
b2d90d2800 docs(appstore): golden template — брать имя шаблона из pveam available; env-пример
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
- docs/golden-template.md: шаг 1 разбит на 1a–1d, имя vztmpl берётся из
  `pveam available` в переменную и переиспользуется (фикс no such template /
  volume does not exist)
- backend/.env.example: добавлены APPSTORE_GOLDEN_TEMPLATE и APPSTORE_HOST_ARCH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:43:25 +05:00
markmorado
799f18d88d fix(appstore): локализация названий категорий (фильтр и бейджи)
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 12s
Категории приложений (gaming, media, network, …) отображаются переведёнными
подписями через i18n (appstore.categories.*, ru+en) при сохранении сырого ключа
как значения фильтра. Неизвестные категории показываются как есть (fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:42:59 +05:00
markmorado
635947eb15 fix(appstore): рендер markdown/скриншотов, фильтр категорий, локализация ошибки, ширина карточек
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
- Описания приложений: безопасный рендер markdown + HTML (react-markdown +
  remark-gfm + rehype-raw + rehype-sanitize), скриншоты отображаются как картинки
- Фильтр по категории: переведён на надёжную фильтрацию в Python (JSONB-оператор
  на generic JSON-колонке отрабатывал ненадёжно)
- Ошибка отсутствия золотого шаблона локализована (i18n appstore.golden_missing)
- Каталог: карточки шире (макс 3 колонки вместо 4), описание в 3 строки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:28:19 +05:00
markmorado
490c4ad18d docs(appstore): актуализировать README/CHANGELOG/WIKI, версия 1.8.0
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 12s
- VERSION → 1.8.0
- CHANGELOG (ru/en): запись v1.8.0 с разделом App Store
- README (ru/en): бейдж версии + категория App Store в возможностях
- WIKI: раздел «App Store» (предпосылки, env, install, My Apps, права, REST API) + оглавление

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:12:13 +05:00
markmorado
0606e8ac77 feat(appstore): модуль «App Store» — каталог self-hosted приложений (1 app = 1 LXC + Docker Compose)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m4s
Deploy to Production / Trigger production update (push) Successful in 12s
Полный цикл M0–M5 по ТЗ:
- Catalog Service: импорт runtipi/runtipi-appstore → catalog_apps, идемпотентный
  синк, кэш логотипов, авто раз в 24ч (APScheduler), флаг update_available.
- App Engine: установка (клон золотого шаблона → pct push compose/.env →
  docker compose up → health-check) с журналом шагов, шифрованием env (Fernet),
  генерацией random-секретов и прогрессом по WebSocket.
- Lifecycle: start/stop/restart/logs/delete + реконсиляция состояния (orphaned).
- Update/Rollback через снапшоты Proxmox (pre-update, хранение последнего 1).
- UI: экраны App Store (сетка/поиск/категории), страница приложения, мастер
  установки (динамическая form_fields + advanced + прогресс + креденшелы),
  My Apps; i18n ru/en; атрибуция Runtipi.
- RBAC app:view/install/manage; только unprivileged LXC; pct exec/push без
  shell-инъекций (base64 + экранирование).
- Модели catalog_apps/installed_apps/app_operations (миграции 31–32).
- Docs: golden-template, appstore-poc/catalog/engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:05:20 +05:00
markmorado
782524fff6 feat(hardware): проброс физических дисков ноды в VM (raw passthrough)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Successful in 7s
Добавлена возможность пробросить физический диск ноды в VM по стабильному
пути /dev/disk/by-id/ (scsiN/virtioN/sataN) в карточке «Проброс устройств».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:37:13 +05:00
markmorado
bec7b20e31 feat(auth): двухфакторная аутентификация (TOTP) для входа в панель
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Successful in 8s
Бэкенд:
- two_factor_service: генерация секрета, otpauth-URI, QR как inline-SVG,
  одноразовые backup-коды (хранятся как SHA-256)
- эндпоинты /api/auth/2fa/{status,setup,enable,disable}; гейт 2FA в login
  (неверный код -> 400, чтобы SPA не сбрасывала форму); two_factor_enabled в /me
- админский сброс: POST /api/users/{id}/disable-2fa
- модель/схемы/init.sql + идемпотентная миграция №30 (two_factor_backup_codes)
- зависимости: pyotp, qrcode

Фронтенд:
- двухшаговый вход с полем кода (TOTP/backup)
- карточка 2FA в профиле: QR, проверка, backup-коды, отключение
- бейдж/сброс 2FA в списке пользователей; локали ru/en

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:08:38 +05:00
markmorado
fc42e8fc19 feat(nodes): показывать версию PVE, рабочие области и аптайм на карточках узлов
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s
Карточки на странице «Узлы» дополнены важной информацией:
- версия Proxmox VE (чип «PVE x.y.z»);
- число нод в кластере (для кластерных серверов);
- аптайм узла;
- чипы рабочих областей, в которых состоит сервер.

Бэкенд: эндпоинт /api/{id}/nodes отдаёт version; ответ серверов
дополнен полями cluster_name и workspaces (ServerWorkspaceBrief).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:37:28 +05:00
markmorado
476b73b411 docs(metrics): актуализировать комментарии интервалов брокера
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m2s
Deploy to Production / Trigger production update (push) Successful in 8s
Реальные интервалы циклов: dashboard 2с, server 2с, instance 1с.
В комментариях было устаревшее 10с/5с. Логику не меняем — дашборд
уже обновляется раз в 2 секунды.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:30:56 +05:00
markmorado
b9e5ef9426 fix(dashboard): не обнулять узлы кластера при REST-рефетче и сбоях WS
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 56s
Deploy to Production / Trigger production update (push) Failing after 9s
Данные по узлам (nodes) приходят только из WebSocket dashboard_metrics,
а REST /api/resources/all их не содержит. Каждый 15-секундный рефетч REST
затирал ноды -> таблица узлов и счётчик обнулялись, WS возвращал через 2с.

- frontend: при REST-рефетче переносим последние известные nodes из кэша,
  чтобы node-таблица не мигала
- backend: кэш последнего удачного опроса по каждому серверу
  (_server_last_good) — транзиентный таймаут Proxmox больше не отдаёт
  пустые vms/containers/nodes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:56:08 +05:00
markmorado
d4c5c6110a fix(dashboard): устранить мигание показаний и поток ошибок 422
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 58s
Deploy to Production / Trigger production update (push) Failing after 10s
- поднять лимит /proxmox/api/all-tasks до 500 (дашборд запрашивал limit=500
  при потолке le=200, что давало 422 каждые 5с)
- обработчик WS dashboard_metrics больше не затирает кэш пустыми/частичными
  тиками: игнорирует пустые сводки и сохраняет последние валидные
  nodes/vms/containers при транзиентном сбое Proxmox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:49:54 +05:00
c0296e47b8 Merge pull request 'feat(ui): design polish — semantic status tokens, self-hosted fonts, a11y, skeletons' (#1) from ui/design-polish into main
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
Reviewed-on: #1
2026-07-04 06:42:06 +05:00
209 changed files with 27811 additions and 2704 deletions

10
.gitignore vendored
View File

@@ -27,6 +27,12 @@ node_modules/
# Generated systemd files (created by deploy.sh when not root)
systemd/
# Let's Encrypt state written by certbot (contains private keys — never commit)
nginx/certbot/
# Generated per deployment by setup_nginx_config() from the .template files,
# with the domain substituted in — deployment state, not source.
nginx/conf.d/pvemanager.conf
# Environment
.env
.env.local
@@ -35,6 +41,7 @@ systemd/
# Editors
.vscode/
.idea/
.claude/
# System files
.DS_Store
@@ -77,3 +84,6 @@ graphify-out/
# Local-only Superpowers artifacts (specs, plans, SDD scratch) — never pushed
docs/superpowers/
.superpowers/
# Планы разработки не коммитим в репозиторий
docs/plans/

View File

@@ -4,6 +4,267 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.16.1] - 2026-08-17
### 🎨 UI
- **Inter Variable font** — the frontend now uses the variable-weight Inter font with a bumped type scale for sharper, more consistent typography across the panel
### 🔧 Deployment Fixes
- **`pve` CLI installs without root** — `deploy.sh` now tries `sudo` to install the CLI to `/usr/local/bin`, and falls back to `~/.local/bin` (with automatic PATH configuration) when root access is unavailable; previously the auto-install was silently skipped, leaving the `pve` command unavailable after deploy
- **`pve` CLI file permissions** — the installed script is now set to `755` instead of relying on umask; previously `sudo cp` could leave the file without read permission for non-root users, causing "Permission denied" on every invocation
- **SSL no longer rolls back on deploy** — `verify_nginx_running` now uses `docker compose ps --format '{{.State}}'` (returns `"running"` immediately) instead of grepping the human-readable STATUS column (which shows `"Up Xs (health: starting)"` for 30 s before the first healthcheck); with the old 20 s timeout the function always timed out, rolling back to HTTP even though the SSL certificate was successfully obtained
---
## [v1.16.0] - 2026-08-14
### 🌍 i18n — Full Translation Pass
A comprehensive sweep that eliminated the remaining hardcoded and untranslated strings from the UI:
- **Dashboard & Instances** — action dialogs, resource cards, status labels, and bulk-operation descriptions are now fully translated (EN + RU)
- **Images page** — image browser, download/import dialogs, mirrors manager fully translated
- **App Store** — missing keys added for install wizard, operation states, and app detail labels
- **Nodes** — power action labels (`Start`, `Reboot`, `Shutdown`) and common node-admin strings translated
- **Scripts & Certs** — Scripts catalog and TLS/certificates sections added to both locales
- **Roles** — permission labels for all RBAC entries, including `remote_migrate`, translated to Russian
- **Graphs, Wizard, Storages** — previously hardcoded Russian strings moved to locale files and dual-translated
- **Bulk migrate & Access realm** — new locale keys for bulk migration actions and Proxmox realm labels
- **Critical fixes** — several strings that were silently falling back to English in the Russian UI are now correctly translated
### 🔑 SSH Keys
- **Swap SSH keys on owner change** — when an admin reassigns a VM/LXC to a different user, the instance now automatically receives the new owner's SSH public key (and the previous owner's key is removed), keeping access credentials in sync with the current owner
### 📦 Cloud Images
- **`patch-cloud-images.sh`** — new helper script that patches official cloud images to include `qemu-guest-agent`, then publishes them to a GitHub release mirror; enables live metrics and guest-agent features without requiring a post-deploy agent install
- **amd64 image catalog** — default amd64 image entries now point to the GitHub mirror (qemu-guest-agent pre-installed) and include `sha256` checksums for integrity verification
### 🐛 Bug Fixes
- **VM storage overview** — loop devices (`/dev/loop*`) and system partitions (`/dev/sr*`, `/boot`, `/snap`) were included in per-disk usage cards; they are now filtered out so only data disks are shown
- **Cloud image re-download** — cached import images were served from disk even when a checksum was provided and the remote file had changed; the cache is now invalidated when a checksum mismatch is detected
- **Password visibility icon** — the eye icon across all password fields (login, change-password, SSH key passphrase, and API-key dialogs) was inverted: open eye was shown when the field was hidden and vice-versa; icon state now correctly reflects whether the value is visible
---
## [v1.15.0] - 2026-08-13
### 🚀 One-Command Bootstrap
- **`bootstrap.sh`** — installs the panel with a single command on either a plain Debian/Ubuntu host or directly on a Proxmox VE host. On a PVE host it provisions a Debian 12 LXC, installs the panel inside it via the existing `deploy.sh`, creates a local `root@pam` API token and registers that host as the panel's first node — no Proxmox password is ever transmitted
- Tolerates `apt-get update` failures from an unsubscribed PVE host's enterprise repo instead of aborting the whole bootstrap before `git`/`curl` are installed
- Registers the host at the correct `/proxmox/api/servers` path (the previous `/api/servers` 404'd, since the Proxmox router is mounted under `/proxmox`)
- Removed the insecure `pvemanager_secure_password` fallback for `POSTGRES_PASSWORD` in `compose.yml` — every deploy path already generates a real `.env` before the stack starts
### 💿 Images & Instances
- **ISO eject/mount rework** — the Images page now shows real ISO files already present on node storage (including ones that predate the node joining the panel); "Detach" is renamed **Eject**, the current image is shown inside the select, and a new **"boot from disk"** / **"boot from this ISO"** checkbox controls boot order on eject/mount
- Applying a new boot order reboots the guest via a **hybrid restart** — graceful ACPI shutdown with a forceStop fallback, then start — avoiding the guest-ping timeout seen on a freshly installed OS with no QEMU Guest Agent yet
### 🔒 RBAC & Access
- **`/profile` split from `/settings`** — account settings (profile, password, 2FA, SSH keys) moved to their own permission-free `/profile` page reached from the user menu and Ctrl+K, so revoking `setting:view` to keep a user out of panel settings no longer locks them out of their own account. `/settings` keeps the panel-wide tabs behind `setting:view`, with saving behind `setting:update`, security behind `setting:manage`, and SMTP/Telegram behind admin-only
- **Routes are now guarded by permission**, not just hidden from the sidebar — `/users`, `/logs` and `/settings` (and their tabs) previously rendered for anyone who typed the URL and let the backend answer with 403s; denied users are now redirected to the first page they can open
- Migration 42 drops `setting:view` from roles that don't also hold `setting:update`/`setting:manage`, since those grants only existed to reach the old combined settings page
### 👤 Ownership & Quotas
- **Owner assignment fixed at creation time** — newly created VMs/LXCs were silently ending up with `owner_id = NULL` because the sync job that registers a still-cloning VM beats the deploy flow to the database row; owner is now set on that update path too. A VM created by an admin on behalf of a user now also injects the **user's** SSH key instead of the admin's
- **Backfill migration** for instances created before the fix above, recovered from `deploy_tasks` and `audit_logs` and applied only when every piece of evidence agrees on the same existing user; ambiguous instances are left ownerless for `assign_instances_owner.py` to resolve by hand
- **Create Instance is disabled once a quota has no headroom left**, instead of only refusing inside the wizard — the button shows a tooltip naming which limits are full (skipped for admins, whose own quota doesn't apply to instances they create for someone else)
- The pre-wizard quota block is now distinct from the in-wizard one: no headroom at all keeps the wizard from opening; headroom that a chosen configuration would exceed opens the wizard but disables Next/Deploy with the offending metric named
### 🐛 Bug Fixes
- **Production deployments were bypassing TLS** — `compose.prod.yml`'s `ports: []` didn't actually drop the published `8000`/`3001` ports (Compose concatenates list-valued fields across `-f` files instead of overriding), so an SSL deployment kept answering unencrypted on both ports alongside HTTPS. Fixed with the `!reset` tag
- **SSL deployments reported "Failed to obtain SSL certificate" after successfully issuing one** — the success check couldn't read certbot's root-owned, mode-0700 output directory as a non-root user; the check now verifies the certificate from inside the container and falls back to HTTP only if nginx actually fails to come up with the new config
- **The frontend bundle's cold load could render a blank page** — the general request-rate limit (`10r/s`) applied to static assets too and started rejecting the SPA's ~86 preloaded chunks with 503s; and once that was fixed, `limit_conn addr 10` capped the whole page to 10 concurrent HTTP/2 streams (a browser multiplexes a page's entire load over one connection), producing the same blank page from a different limiter. Static assets now have their own rate-limit zone, and both `limit_conn` caps are raised
- **nginx workers were unable to open enough file descriptors** to match `worker_connections 2048` (Docker's default `RLIMIT_NOFILE` of 1024 quietly halved the real ceiling); raised to 8192
- Dropped `listen 443 ssl http2` (deprecated since nginx 1.25.1) and `ssl_stapling` (no OCSP responder in current Let's Encrypt certs) — both warned on nginx's periodic reload
- **Node uptime showed the wrong value on multi-node clusters** — the server card took `Math.max` over every cluster node's uptime instead of the current node's own, so rebooting one node could still show another node's higher uptime
- **The terminated-sessions toast never translated** — the message was assembled server-side in Python and returned verbatim, so the Russian UI showed "Terminated 0 sessions"; the count is now returned separately and the phrase is composed client-side (Russian needs four plural forms against English's two)
---
## [v1.14.0] - 2026-07-24
### 🐛 Bug Fixes
- **Workspace switch left stale instance filters behind** — the server, node and per-column filters on the Instances page kept whatever values were picked in the previously active workspace, which could filter the newly-scoped VM list down to nothing. Switching the active workspace now resets all of them
- **"Node" column filter never updated after the first render** — its checklist of available nodes was memoized against the TanStack `Column` object, which keeps the same identity across data updates, so the list stayed frozen at whatever nodes were visible when the popover first mounted — including nodes from workspaces you'd since switched away from. It now reads live from the table's faceted values on every render
---
## [v1.13.0] - 2026-07-24
### 🛒 App Store
- **Storage selection for the golden template** — the "Golden LXC template" dialog now offers dropdowns for template storage (`vztmpl` content) and rootfs storage (`rootdir` content) populated from the selected server/node, instead of free-text fields. Selection auto-picks `local`/`local-lvm` when present, falling back to the first available storage
### 📜 Scripts
- **Name search in the scripts catalog** — filter manual, Git and Community-Scripts entries by name directly from the Scripts page
---
## [v1.12.0] - 2026-07-24
### 🔗 Cluster
- **Import an existing cluster** — nodes already joined into a Proxmox cluster outside the panel (via `pvecm`/the Proxmox UI) can now be linked in the Cluster page instead of only via the panel's own Create/Join flow. Detection reads `cluster/config/join` on the selected node, matches its nodelist against known panel servers by IP/hostname, and links the matching panel records to that cluster group — no `pvecm` operation is run, only the panel's own bookkeeping is updated
---
## [v1.11.0] - 2026-07-24
### 🔌 Servers & Connectivity
- **Auto-provisioned API tokens** — connect a server with a login and password once; the panel authenticates, creates its own API token on the node (`privsep=0`) and uses it from then on. The token can be revoked from the Proxmox UI, cannot be used for SSH or web login, and appears in the PVE audit log under its own name. Existing password-auth servers can be migrated in place via **Provision token**
- **Rejected credentials vs. unreachable node** — an offline server used to report a single "Failed to connect"; now a stale/rejected token (401, typically left behind after a cluster join replaces `user.cfg`/`token.cfg`) shows its own `auth_error` state with a hint explaining the cause
- **Per-node storage availability** — a storage can be declared cluster-wide but missing on a given node (e.g. an absent volume group or pool); this is now surfaced instead of staying invisible
- **Unified status badges** — every server list (including cluster topology and user-assignment views) renders the same `ServerStatusBadge` instead of a repeated online/offline ternary
- **Workspace-scoped listings** — `/api/cluster/topology` and `/api/resources/all` now respect the active workspace instead of returning every server on the panel; live metrics are fetched in parallel instead of serially, so one unreachable cluster no longer stalls the whole request
### 📊 Monitoring
- **Node Graphs tab** — CPU, IO delay, memory, load average, network, swap and root filesystem charts on the node detail page, with `1h/24h/7d/30d/1y` presets
### 💿 Images & Instance Creation
- **Local file upload** — upload an ISO or `vztmpl` directly from your machine to node storage, alongside the existing URL-download flow
- **Blank VM for ISO installs** — a new instance kind in the create wizard: an empty-disk VM with the ISO mounted and booted first, so the guest starts straight into the installer over noVNC; picking Windows presets OVMF, a SATA disk and an e1000 NIC
- **ISO downloads** — fetch an ISO onto a node by URL from the Images page or from a saved mirror (mirrors now accept `kind=iso`)
### 🔄 Realtime & UX
- Instance and workspace lists now update immediately after assigning servers/users, deploying, or deleting a VM/container — no manual refresh needed
- The node overview no longer flashes "no data" while the instance list is still loading
### 🛠 Reliability
- **Panel self-update recovers from a stalled rebuild** instead of hanging at "restarting — 80%" forever; after a timeout it surfaces a clear "failed" state pointing at the host update logs
### 🎨 Theme
- Dark theme surfaces (background, card, popover, sidebar, borders) shifted from neutral to a blue-graphite hue
### 📖 Documentation
- Expanded the RBAC/Security guide (permission format, evaluation order, full permission catalogue, per-page map) and added a complete Russian translation, **WIKI.ru.md**
---
## [v1.10.2] - 2026-07-23
### 🐛 Fixes
- **Templates** — after an auto-import, a clear message is shown instead of `undefined`
---
## [v1.10.0] - 2026-07-22
### 🧭 Navigation & Instances
- **Command palette (Ctrl+K)** — searchable palette over a shared registry of navigation items (its own store), for jumping to any page from the keyboard
- **Unified navigation** — the sidebar and command palette are driven by one shared nav-item registry
- **Expanded instance menu** — the per-instance row menu gains **Options**, **Migrate**, **Change resources**, **Create backup** (new `BackupDialog`) and **Delete**; the menu no longer closes on every live list refresh (stable `columns` + `getRowId`)
- **Instance tab reorganization** — for VMs, *Compute resources*, *CPU options*, *Disk management* and *Disk resize* moved from **Settings** to **Hardware**; LXC (which has no Hardware tab) keeps them in **Settings**. Shared cards extracted into `ResourceCards.tsx`
- **New shared UI primitives** — `alert`, `breadcrumb`, `checkbox`, `progress`, `switch`, `textarea`; RU/EN locale keys for the new menu items and backup
### 🛒 App Store
- **Global minimized-operations tray** — minimized long-running operations (app install, golden-template build) now live in a global zustand store (persisted to `localStorage`) and are rendered by a `MinimizedOpsTray` in the app layout instead of by the dialog itself:
- the chip **survives page navigation and a tab reload**; the tray polls progress on its own (REST, every 3 s) independently of the dialog
- clicking a chip returns to the operation's page and reopens the dialog — install resumes in `InstallWizard` (resume mode via navigation state), template reopens the golden dialog with the server preselected
- **Minimize** adds the operation to the tray and closes the dialog; the chip's ✕ only removes the chip (the operation keeps running on the server); chips for operations that no longer exist (404 after reload) are cleared automatically
- **App data files** — files bundled with a catalog app (`data_files`) are delivered before `compose up`; Umbrel `APP_PASSWORD` / `default_credentials` are honored
- **Minimizable install/build dialogs** — long operations can be collapsed via `MinimizableDialog`
- **Install/uninstall reliability**:
- **teardown** — a CT that no longer exists in Proxmox is treated as removed, so an `orphaned` app can finally be deleted from the DB
- **install** — the VMID search skips already-occupied records (`nextid` does not reserve the number, so a retry could grab another container)
- **retry** — install parameters (`cores/memory/disk/storage/bridge/ip_config/port`) are saved to `installed_apps.install_params` (JSONB, new migration) and reused on retry instead of falling back to hard-coded defaults
- **reconcile** — an unreachable server no longer flags all of its apps as `orphaned`
- **update** — the actually-published port is recorded in the DB (`url`/health)
- **.env** — values containing `#` are quoted (the dotenv parser was trimming them as an inline comment)
- port `80`, when chosen explicitly in the wizard, is published as-is
- `useCatalog` — `available_only` is part of the query key (fixes a cache collision)
### 🔒 Owner Isolation
- **Live Proxmox resources are now owner-scoped** — the instance list (`/api/virtual-machines`) was already filtered by `owner_id`, but the live-resource endpoints `/api/resources/all` (dashboard) and `/api/{server_id}/resources` (node/server page) returned **all** VMs/LXC. A regular user (`proxmox.view`) could see other owners' instances through the node page. Both endpoints now filter VM/LXC by owner for non-privileged users (matching `vmid` against the `vm_instances` cache); admins and roles with `vm:manage` still see everything
- **`assign_instances_owner.py`** — script to bulk-assign an owner to existing instances whose `owner_id` is `NULL` (instances synced from Proxmox before ownership existed)
### 🐛 Fixes
- **React Query cache cleared on login/logout** — logging in as a different user in the same tab no longer serves the previous user's cached data (instance list, etc.) until a hard reload. `queryClient` was moved out of `App.tsx` into `lib/query-client.ts` so it is reachable outside React, and `auth-store` calls `queryClient.clear()` on successful login and on logout
---
## [v1.9.1] - 2026-07-17
### 🛒 App Store
- **Per-server golden template** — the golden vztmpl is now stored and resolved per Proxmox `server_id` instead of one global pointer; building it on one server no longer overwrites/breaks the template configured for another. `APPSTORE_GOLDEN_TEMPLATE` is now only the last-resort fallback for servers without their own build. Migration 37 adds `app_operations.server_id`
- **Golden template build fix** — the automated build (`App Store → золотой шаблон`) no longer fails at the final `vzdump` export step (`unable to parse directory volume name 'vztmpl/probe'`)
- **DNS reliability** — App Store LXCs (and the golden-template build CT) now get an explicit `nameserver` (`APPSTORE_NAMESERVER`, default `1.1.1.1 8.8.8.8`) instead of inheriting the node's resolver, which fixed image-pull timeouts on nodes whose DNS is only reachable from the host (e.g. over Tailscale)
- **Bind-mount permissions** — data directories referenced in an app's `volumes:` (`${APP_DATA_DIR}/...` or relative paths) are pre-created before `up`, fixing `Permission denied` for containers that write as a non-root user (e.g. MariaDB)
- **Delete/reinstall race fixed** — deleting an app now waits for Proxmox to actually confirm the container is gone (up to 30 min, covers slow disk wipes) before freeing the VMID, instead of assuming success after a short timeout
- **`pct create` lock retry** — transient "can't lock file ... got lock" errors right after a delete are retried a few times instead of failing the install immediately
- **Full error output** — failed pipeline steps now surface the last 4000 characters of command output (previously the first 2000), so the actual failure reason isn't buried under `docker compose pull` progress noise
- **Install error UI** — the error box in the install dialog now wraps long unbroken text (image digests, paths) and scrolls within its own bounded height instead of stretching the dialog
---
## [v1.9.0] - 2026-07-09
### 🌐 IPAM
- **Networks bound to server/node and workspace** — an IPAM network can now be tied to a specific Proxmox server/node and to a workspace; each workspace uses one subnet, and the network is auto-selected by the active workspace in the Create Instance and App Store install wizards, so users no longer pick a network manually in the common case
- **Auto-selection in wizards** — the deploy wizards resolve the target network/pool from the current scope and pre-fill it; a missing binding falls back to manual selection
- Migration: IPAM network workspace-binding columns + default flag added via `backend/migrations/migrations.py`
### 🔧 Nodes
- **FQDN/domain accepted in the Proxmox host field** — the Add/Edit Proxmox server form now accepts a domain name or FQDN (not only an IP), resolved when connecting
---
## [v1.8.1] - 2026-07-09
### 🛒 App Store
- **Umbrel as a second catalog source** — the catalog is now multi-source: alongside `runtipi/runtipi-appstore` you can enable `getumbrel/umbrel-apps` (~380 apps). Sources are configured via `APPSTORE_SOURCES` (comma-separated, default `runtipi`); Umbrel app ids are namespaced (`umbrel-<id>`) so catalogs never collide
- **Umbrel compose adapter** — install understands Umbrel manifests: the container port is taken from the `app_proxy` service (`APP_PORT`), the Umbrel runtime services (`app_proxy`, `tor`) are stripped, dangling `depends_on` are cleaned, and the main service port is published on the LXC host so the health-check passes; Runtipi behaviour is unchanged
- **Umbrel icons** — icons are fetched from the separate `getumbrel/umbrel-apps-gallery` repository (`<app-id>/icon.svg`) during sync and cached like Runtipi logos; a gallery fetch failure never fails the catalog sync
- **Multi-source sync** — each source syncs independently (a network failure of one doesn't cancel the others), and the *disappeared → unavailable* pass is scoped per source so a Runtipi sync never touches Umbrel rows
- **Source filter & badge (UI)** — the App Store adds a source filter and a per-card source badge, shown only when more than one source is enabled; catalog metadata now exposes the available `sources`
- **Custom install port** — the install wizard accepts a custom host port (validated 1..65535), overriding the catalog default
- **Instant instance cleanup** — deleting an app soft-deletes the linked VM instance immediately for a snappier UI
- **Data model & settings** — `catalog_apps.source` column (migration 34); new env settings `APPSTORE_SOURCES`, `UMBREL_APPSTORE_REPO`, `UMBREL_APPSTORE_REF`, `UMBREL_APPSTORE_GALLERY_REPO`
---
## [v1.8.0] - 2026-07-07
### 🛒 App Store
- **Self-hosted app catalog with one-click install** — new App Store module: each application is deployed into its own **unprivileged LXC** container running **Docker Compose** (1 app = 1 LXC). The catalog is imported from the `runtipi/runtipi-appstore` repository (~265 apps pass the filter)
- **Catalog Service** — imports app metadata, logos, descriptions and compose files into the new `catalog_apps` table; idempotent sync (manual *Refresh catalog* button + automatic every 24 h via APScheduler); a broken `config.json` is skipped without failing the whole sync; the `update_available` flag is computed from `tipi_version`
- **Install pipeline** — clone golden LXC template → `pct push` compose/`.env` → `docker compose up -d` → HTTP health-check, with a **live step journal over WebSocket**, dynamic `form_fields` rendering (text/password/email/number/boolean/fqdn), auto-generated `random` secrets, and Fernet-encrypted env storage; generated credentials are shown once
- **Lifecycle management** — start / stop / restart / logs (`docker compose logs`) / delete, plus background **state reconciliation** every 60 s (containers removed outside PVEmanager are flagged `orphaned`)
- **Update & Rollback** — update takes a pre-update Proxmox snapshot, then `docker compose pull && up -d` with health-check; rollback restores the snapshot (data included); only the latest pre-update snapshot is kept
- **UI** — App Store grid (search, categories), app detail page with a source disclaimer and compose preview, install wizard with advanced LXC options and live progress, and a **My Apps** management screen; RU/EN locales; *Powered by Runtipi appstore* attribution
- **Security** — unprivileged LXC only; `pct exec` / `pct push` via SSH with base64 file delivery and escaped `.env` values (no shell injection); new RBAC permissions `app:view` / `app:install` / `app:manage`
- **Data model & settings** — `catalog_apps`, `installed_apps`, `app_operations` tables (migrations 31–32); new env settings `APPSTORE_GOLDEN_TEMPLATE`, `RUNTIPI_APPSTORE_REF`, `APPSTORE_DATA_DIR`, `CATALOG_SYNC_INTERVAL_HOURS`, `APPSTORE_HOST_ARCH`
- **Docs** — `docs/golden-template.md` (golden LXC template preparation) and `docs/appstore-poc.md` / `appstore-catalog.md` / `appstore-engine.md`
---
## [v1.7.0] - 2026-06-26
### 🎯 Quotas

View File

@@ -4,6 +4,267 @@
---
## [v1.16.1] - 2026-08-17
### 🎨 Интерфейс
- **Шрифт Inter Variable** — фронтенд переведён на вариативный шрифт Inter с увеличенной шкалой размеров для более чёткой и единообразной типографики по всей панели
### 🔧 Исправления деплоя
- **CLI `pve` устанавливается без root** — `deploy.sh` теперь пробует `sudo` для установки CLI в `/usr/local/bin`, а при недоступности root откатывается на `~/.local/bin` (с автоматической настройкой PATH); ранее авто-установка молча пропускалась, и команда `pve` оставалась недоступной после деплоя
- **Права файла CLI `pve`** — установленный скрипт теперь явно получает права `755` вместо зависимости от umask; ранее `sudo cp` мог оставить файл без права на чтение для не-root пользователей, вызывая «Permission denied» при каждом запуске
- **SSL больше не откатывается при деплое** — `verify_nginx_running` теперь использует `docker compose ps --format '{{.State}}'` (возвращает `"running"` мгновенно) вместо grep по человекочитаемому столбцу STATUS (который показывает `"Up Xs (health: starting)"` первые 30 секунд до первого healthcheck); со старым таймаутом 20 секунд функция всегда завершалась по таймауту и откатывала конфиг на HTTP, хотя SSL-сертификат был успешно получен
---
## [v1.16.0] - 2026-08-14
### 🌍 i18n — Полный проход по переводам
Масштабная зачистка оставшихся захардкоженных и непереведённых строк интерфейса:
- **Дашборд и инстансы** — диалоги действий, карточки ресурсов, метки статусов и описания массовых операций полностью переведены (EN + RU)
- **Страница «Образы»** — браузер образов, диалоги загрузки/импорта, менеджер зеркал — полный перевод
- **App Store** — добавлены недостающие ключи для мастера установки, состояний операций и меток страницы приложения
- **Ноды** — метки силовых действий (`Запуск`, `Перезагрузка`, `Выключение`) и общие строки администрирования нод переведены
- **Скрипты и сертификаты** — разделы каталога скриптов и TLS-сертификатов добавлены в оба локейла
- **Роли** — метки всех RBAC-разрешений, включая `remote_migrate`, переведены на русский
- **Графики, мастер, хранилища** — ранее захардкоженные русские строки вынесены в файлы локали и переведены на оба языка
- **Массовая миграция и Access realm** — добавлены ключи для действий массовой миграции и меток реалмов Proxmox
- **Критические исправления** — несколько строк, молча откатывавшихся на английский в русском интерфейсе, теперь корректно отображаются на русском
### 🔑 SSH-ключи
- **Смена SSH-ключей при смене владельца** — когда администратор переназначает VM/LXC другому пользователю, инстанс автоматически получает SSH-ключ нового владельца (ключ предыдущего при этом удаляется), так что учётные данные для доступа всегда соответствуют текущему владельцу
### 📦 Облачные образы
- **`patch-cloud-images.sh`** — новый вспомогательный скрипт, который патчит официальные облачные образы, добавляя `qemu-guest-agent`, и публикует их в GitHub-зеркало; позволяет получить живые метрики и функции гостевого агента без установки агента после деплоя
- **Каталог образов amd64** — записи образов amd64 по умолчанию теперь указывают на GitHub-зеркало (с предустановленным qemu-guest-agent) и включают контрольные суммы `sha256` для проверки целостности
### 🐛 Исправления
- **Обзор дисков VM** — loop-устройства (`/dev/loop*`) и системные разделы (`/dev/sr*`, `/boot`, `/snap`) включались в карточки использования диска; теперь они отфильтровываются и отображаются только диски с данными
- **Повторная загрузка облачных образов** — закешированные импортированные образы раздавались с диска даже при наличии контрольной суммы и изменении файла на удалённом источнике; теперь кеш инвалидируется при несовпадении суммы
- **Иконка видимости пароля** — значок глаза во всех полях пароля (вход, смена пароля, парольная фраза SSH-ключа, диалоги API-ключей) был инвертирован: открытый глаз отображался, когда значение было скрыто, и наоборот; теперь состояние иконки корректно отражает видимость значения
---
## [v1.15.0] - 2026-08-13
### 🚀 Установка одной командой
- **`bootstrap.sh`** — устанавливает панель одной командой как на обычном хосте Debian/Ubuntu, так и напрямую на хосте Proxmox VE. На хосте PVE скрипт создаёт LXC на Debian 12, устанавливает панель внутри через существующий `deploy.sh`, создаёт локальный API-токен `root@pam` и регистрирует этот хост как первую ноду панели — пароль от Proxmox никуда не передаётся
- Скрипт переживает сбой `apt-get update` из-за недоступного enterprise-репозитория на неподписанном хосте PVE вместо того, чтобы прерывать весь bootstrap ещё до установки `git`/`curl`
- Регистрация хоста теперь идёт по правильному пути `/proxmox/api/servers` (раньше использовался `/api/servers`, отвечавший 404, — роутер Proxmox подключён под префиксом `/proxmox`)
- Убран небезопасный дефолт `pvemanager_secure_password` для `POSTGRES_PASSWORD` в `compose.yml` — на любом пути установки `.env` с реальным паролем уже создаётся до запуска стека
### 💿 Образы и инстансы
- **Переработка извлечения/монтирования ISO** — страница «Образы» теперь показывает реальные ISO-файлы, уже лежащие в хранилище ноды (включая те, что появились там до подключения ноды к панели); «Отсоединить» переименовано в **«Извлечь»**, текущий смонтированный образ отображается прямо в списке выбора, а новый чекбокс **«загрузка с диска»** / **«загрузка с этого ISO»** управляет порядком загрузки при извлечении/монтировании
- Применение нового порядка загрузки перезагружает гостевую систему через **гибридный рестарт** — штатное ACPI-выключение с откатом на принудительную остановку, затем запуск — что убирает таймаут ping гостя на свежеустановленной ОС, где ещё нет QEMU Guest Agent
### 🔒 RBAC и доступ
- **`/profile` отделён от `/settings`** — настройки аккаунта (профиль, пароль, 2FA, SSH-ключи) переехали на собственную страницу `/profile`, не требующую никакого разрешения, и открываются из меню пользователя и Ctrl+K. Теперь отзыв `setting:view`, чтобы не пускать пользователя в настройки панели, больше не блокирует доступ к его собственному аккаунту. `/settings` по-прежнему скрывает общепанельные вкладки за `setting:view`, сохранение — за `setting:update`, безопасность — за `setting:manage`, а SMTP/Telegram — только для админов
- **Маршруты теперь защищены по правам**, а не просто скрыты из сайдбара — раньше `/users`, `/logs` и `/settings` (и их вкладки) открывались по прямому переходу на URL кому угодно, а бэкенд лишь отвечал 403 на конкретные запросы; теперь пользователь без доступа перенаправляется на первую доступную ему страницу
- Миграция 42 снимает `setting:view` с ролей, у которых нет также `setting:update`/`setting:manage`, — этот доступ раньше выдавался только ради общей страницы настроек, включавшей профиль
### 👤 Владение инстансами и квоты
- **Исправлено назначение владельца при создании** — новые VM/LXC оставались с `owner_id = NULL`, потому что задача синхронизации, регистрирующая ещё клонирующуюся VM, опережала флоу деплоя и первой создавала запись в БД; теперь владелец проставляется и на этом пути обновления записи. VM, созданная админом от имени пользователя, теперь получает SSH-ключ именно **этого пользователя**, а не админа
- **Миграция для бэкфилла** владельца у инстансов, созданных до исправления выше: владелец восстанавливается из `deploy_tasks` и `audit_logs` и проставляется только тогда, когда все доступные данные указывают на одного и того же существующего пользователя; неоднозначные случаи остаются без владельца — для них нужен ручной запуск `assign_instances_owner.py`
- **Кнопка «Создать инстанс» отключается**, когда по какой-то квоте совсем не осталось свободного места, а не только отказывает внутри мастера — подсказка называет, какие именно лимиты исчерпаны (проверка пропускается для админов, чья личная квота не имеет отношения к инстансам, создаваемым для других)
- Блокировка перед открытием мастера теперь отделена от блокировки внутри него: полностью исчерпанная квота не даёт открыть мастер вовсе; квота, которую превысит именно выбранная конфигурация, открывает мастер, но блокирует «Далее»/«Развернуть» с указанием превышенного показателя
### 🐛 Исправления
- **Продакшн-деплой не отключал открытые порты** — `ports: []` в `compose.prod.yml` на деле ничего не убирал (Compose при слиянии нескольких `-f` файлов не заменяет списковые поля, а объединяет их), поэтому SSL-деплой продолжал отвечать без шифрования на портах `8000`/`3001` параллельно с HTTPS. Исправлено через тег `!reset`
- **SSL-деплой сообщал о неудаче даже после успешного выпуска сертификата** — проверка успеха не могла прочитать директорию certbot, принадлежащую root и имеющую права 0700, если деплой запускался не от root; теперь сертификат проверяется изнутри контейнера, а откат на HTTP выполняется только если nginx реально не поднялся с новым конфигом
- **Первая загрузка фронтенда могла показать пустую страницу** — общий лимит частоты запросов (`10r/s`) применялся и к статике, отклоняя часть из ~86 подгружаемых чанков SPA с 503; после исправления этого проявился второй лимитер — `limit_conn addr 10` ограничивал всю страницу 10 одновременными HTTP/2-потоками (браузер мультиплексирует всю загрузку страницы через одно соединение), давая тот же эффект пустой страницы. Теперь у статики отдельная зона лимита частоты, а оба лимита `limit_conn` подняты
- **Воркеры nginx не могли открыть достаточно файловых дескрипторов** под `worker_connections 2048` — дефолтный `RLIMIT_NOFILE` Docker в 1024 незаметно урезал реальный потолок вдвое; поднят до 8192
- Убраны `listen 443 ssl http2` (устарела с nginx 1.25.1) и `ssl_stapling` (у текущих сертификатов Let's Encrypt нет OCSP-респондера) — обе директивы выдавали предупреждения при периодическом reload nginx
- **Аптайм ноды показывался неверно в мультинодовых кластерах** — карточка сервера брала `Math.max` по аптайму всех нод кластера вместо аптайма именно текущей, поэтому перезагрузка одной ноды могла по-прежнему показывать более высокий аптайм другой
- **Тост о завершении сессий никогда не переводился** — сообщение собиралось на бэкенде на Python и возвращалось как есть, поэтому в русском интерфейсе показывалось "Terminated 0 sessions"; теперь количество возвращается отдельным полем, а фраза собирается на клиенте (для русского нужно четыре формы множественного числа против двух в английском)
---
## [v1.14.0] - 2026-07-24
### 🐛 Исправления
- **Смена рабочей области не сбрасывала фильтры инстансов** — фильтры по серверу, узлу и столбцам на странице «Инстансы» сохраняли значения, выбранные ещё в предыдущей рабочей области, из-за чего список VM новой области мог отфильтроваться до пустоты. Теперь при переключении рабочей области все эти фильтры сбрасываются
- **Фильтр по колонке «Узел» не обновлялся после первого рендера** — список доступных узлов в нём был замемоизирован на объекте `Column` из TanStack, а этот объект сохраняет свою идентичность при обновлении данных таблицы, поэтому список замирал на тех узлах, что были видны при первом открытии — включая узлы серверов из уже покинутой рабочей области. Теперь значение читается напрямую из faceted-данных таблицы при каждом рендере
---
## [v1.13.0] - 2026-07-24
### 🛒 App Store
- **Выбор хранилищ в golden-шаблоне** — в диалоге «Золотой LXC-шаблон» текстовые поля для хранилища шаблона (`vztmpl`) и rootfs (`rootdir`) заменены на выпадающие списки, заполняемые реальными хранилищами выбранного сервера/ноды. По умолчанию подставляются `local`/`local-lvm`, если они есть, иначе — первое доступное хранилище
### 📜 Scripts
- **Поиск по названию в каталоге скриптов** — фильтрация ручных, Git и Community-Scripts записей прямо на странице Scripts
---
## [v1.12.0] - 2026-07-24
### 🔗 Кластер
- **Импорт существующего кластера** — ноды, уже объединённые в кластер Proxmox в обход панели (через `pvecm` или веб-интерфейс Proxmox), теперь можно привязать прямо на странице «Кластер», а не только через собственный флоу панели «Создать»/«Присоединить». Обнаружение читает `cluster/config/join` на выбранной ноде, сопоставляет её nodelist с уже известными серверами панели по IP/hostname и привязывает совпавшие записи к этой группе кластера — никакая `pvecm`-операция не выполняется, меняется только учёт внутри панели
---
## [v1.11.0] - 2026-07-24
### 🔌 Серверы и подключение
- **Автосоздание API-токенов** — сервер можно подключить по логину и паролю: панель один раз аутентифицируется, создаёт на ноде собственный API-токен (`privsep=0`) и дальше работает по нему. Токен отзывается из UI Proxmox, не даёт SSH- и веб-логина и виден в аудите PVE под своим именем. Уже подключённые по паролю серверы переводятся на токен без переподключения кнопкой **«Перевести на API-токен»**
- **Отклонённые учётные данные vs. недоступная нода** — раньше офлайн-сервер показывал единое «Не удалось подключиться»; теперь протухший/отклонённый токен (401, обычно остаётся после того, как вступление ноды в кластер заменяет `user.cfg`/`token.cfg`) получает собственное состояние `auth_error` с подсказкой о причине
- **Доступность storage по нодам** — storage может быть объявлен на весь кластер, но отсутствовать на конкретной ноде (нет volume group или пула); теперь это видно, а не скрыто
- **Единые бейджи статуса** — все списки серверов (включая топологию кластера и назначение пользователей) рендерят один `ServerStatusBadge` вместо повторяющегося тернарника online/offline
- **Списки в рамках рабочего пространства** — `/api/cluster/topology` и `/api/resources/all` теперь учитывают активное рабочее пространство, а не отдают все серверы панели; живые метрики собираются параллельно, а не последовательно, поэтому один недоступный кластер больше не тормозит весь запрос
### 📊 Мониторинг
- **Вкладка «Графики» у ноды** — CPU, IO delay, память, load average, сеть, swap и корневая ФС на странице ноды, с пресетами `1h/24h/7d/30d/1y`
### 💿 Образы и создание инстансов
- **Загрузка локального файла** — ISO или `vztmpl` можно загрузить прямо со своего компьютера в storage ноды, в дополнение к загрузке по URL
- **Пустая VM для установки с ISO** — новый тип инстанса в мастере создания: VM с пустым диском и смонтированным первым по порядку загрузки ISO, гость сразу попадает в установщик через noVNC; выбор Windows задаёт OVMF, SATA-диск и сетевую карту e1000
- **Загрузка ISO** — ISO можно скачать на ноду по URL со страницы «Образы» или из сохранённого зеркала (зеркала теперь принимают `kind=iso`)
### 🔄 Реалтайм и UX
- Списки инстансов и рабочих пространств обновляются сразу после назначения серверов/пользователей, деплоя или удаления VM/контейнера — без ручного обновления
- Обзор ноды больше не мигает «нет данных», пока список инстансов ещё загружается
### 🛠 Надёжность
- **Самообновление панели восстанавливается после зависшей пересборки** вместо вечного «restarting — 80%»; по таймауту показывается понятное состояние «failed» со ссылкой на логи update-сервиса хоста
### 🎨 Тема
- Поверхности тёмной темы (фон, карточки, popover, сайдбар, границы) сдвинуты с нейтрального оттенка на сине-графитовый
### 📖 Документация
- Расширен раздел RBAC/Security (формат прав, порядок проверки, полный каталог прав, карта прав по страницам), добавлен полный русский перевод — **WIKI.ru.md**
---
## [v1.10.2] - 2026-07-23
### 🐛 Исправления
- **Шаблоны** — после авто-импорта показывается понятное сообщение вместо `undefined`
---
## [v1.10.0] - 2026-07-22
### 🧭 Навигация и инстансы
- **Командная палитра (Ctrl+K)** — палитра с поиском поверх общего реестра пунктов навигации (со своим store), позволяет перейти на любую страницу с клавиатуры
- **Единая навигация** — боковое меню и командная палитра работают от одного общего реестра nav-элементов
- **Расширенное меню инстанса** — в меню строки инстанса добавлены **Параметры**, **Мигрировать**, **Изменить ресурсы**, **Создать резервную копию** (новый `BackupDialog`) и **Удалить**; меню больше не закрывается при каждом живом обновлении списка (стабильные `columns` + `getRowId`)
- **Реорганизация вкладок инстанса** — для VM «Вычислительные ресурсы», «Опции CPU», «Управление дисками» и «Изменение размера диска» перенесены со вкладки **Настройки** на **Оборудование**; у LXC (где вкладки «Оборудование» нет) они остаются в **Настройках**. Общие карточки вынесены в `ResourceCards.tsx`
- **Новые общие UI-примитивы** — `alert`, `breadcrumb`, `checkbox`, `progress`, `switch`, `textarea`; ключи локализации RU/EN для новых пунктов меню и бэкапа
### 🛒 App Store
- **Глобальный трей свёрнутых операций** — свёрнутые долгие операции (установка приложения, сборка золотого шаблона) теперь живут в глобальном zustand-сторе (persist в `localStorage`) и рендерятся треем `MinimizedOpsTray` в layout приложения, а не самим диалогом:
- плашка **переживает навигацию между страницами и перезагрузку вкладки**; трей опрашивает прогресс сам (REST, каждые 3 с) независимо от диалога
- клик по плашке возвращает на страницу операции и разворачивает диалог — установка продолжается в `InstallWizard` (resume-режим через navigation state), шаблон открывает диалог золотого шаблона с предвыбранным сервером
- «Свернуть» добавляет операцию в трей и закрывает диалог; крестик на плашке только убирает плашку (операция продолжается на сервере); плашки несуществующих операций (404 после перезагрузки) трей убирает сам
- **Файлы данных приложения** — файлы, поставляемые вместе с приложением каталога (`data_files`), доставляются до `compose up`; учитываются `APP_PASSWORD` / `default_credentials` для Umbrel
- **Сворачиваемые диалоги установки/сборки** — долгие операции можно свернуть через `MinimizableDialog`
- **Надёжность установки/удаления**:
- **teardown** — отсутствующий в Proxmox CT считается удалённым, поэтому `orphaned`-приложение теперь можно удалить из БД
- **install** — поиск VMID сдвигается мимо занятых записей (`nextid` не резервирует номер, из-за чего retry мог подхватить чужой контейнер)
- **retry** — параметры установки (`cores/memory/disk/storage/bridge/ip_config/port`) сохраняются в `installed_apps.install_params` (JSONB, новая миграция) и переиспользуются при повторе вместо жёстких дефолтов
- **reconcile** — недоступный сервер больше не помечает все свои приложения `orphaned`
- **update** — реально опубликованный порт фиксируется в БД (`url`/health)
- **.env** — значения с `#` кавычатся (dotenv-парсер обрезал их как inline-комментарий)
- порт `80`, выбранный явно в мастере, публикуется как есть
- `useCatalog` — `available_only` включён в query key (устранена коллизия кэша)
### 🔒 Изоляция по владельцу
- **Живые ресурсы Proxmox теперь фильтруются по владельцу** — список инстансов (`/api/virtual-machines`) уже фильтровался по `owner_id`, но эндпоинты живых ресурсов `/api/resources/all` (дашборд) и `/api/{server_id}/resources` (страница ноды/сервера) отдавали **все** VM/LXC. Обычный пользователь (`proxmox.view`) через страницу ноды видел чужие инстансы. Оба эндпоинта теперь фильтруют VM/LXC по владельцу для непривилегированных (сверка `vmid` с кэшем `vm_instances`); админы и роли с `vm:manage` по-прежнему видят всё
- **`assign_instances_owner.py`** — скрипт массового назначения владельца существующим инстансам с `owner_id = NULL` (синхронизированным из Proxmox до появления владения)
### 🐛 Исправления
- **Очистка кэша React Query при login/logout** — при входе под другим пользователем в той же вкладке React Query больше не отдаёт закэшированные данные предыдущего пользователя (список инстансов и др.) до жёсткой перезагрузки. `queryClient` вынесен из `App.tsx` в `lib/query-client.ts` (доступен вне React), а `auth-store` вызывает `queryClient.clear()` при успешном входе и при выходе
---
## [v1.9.1] - 2026-07-17
### 🛒 App Store
- **Золотой шаблон отдельно на каждый сервер** — золотой vztmpl теперь хранится и разрешается по `server_id` Proxmox, а не одним глобальным указателем; сборка на одном сервере больше не перезаписывает/ломает шаблон, настроенный для другого. `APPSTORE_GOLDEN_TEMPLATE` теперь только последний запасной вариант для серверов без собственной сборки. Миграция 37 добавляет `app_operations.server_id`
- **Исправление сборки золотого шаблона** — автоматическая сборка (**App Store → золотой шаблон**) больше не падает на финальном шаге экспорта `vzdump` (`unable to parse directory volume name 'vztmpl/probe'`)
- **Надёжность DNS** — LXC приложений App Store (и CT сборки золотого шаблона) теперь получают явный `nameserver` (`APPSTORE_NAMESERVER`, по умолчанию `1.1.1.1 8.8.8.8`) вместо наследования резолвера ноды — это устранило таймауты при загрузке образов на нодах, чей DNS доступен только с хоста (например, через Tailscale)
- **Права bind-mount** — директории данных из `volumes:` приложения (`${APP_DATA_DIR}/...` или относительные пути) заранее создаются перед `up`, что устраняет `Permission denied` для контейнеров, пишущих под non-root пользователем (например, MariaDB)
- **Устранена гонка удаления/переустановки** — удаление приложения теперь ждёт фактического подтверждения Proxmox, что контейнер удалён (до 30 мин, покрывает медленную очистку диска), прежде чем освободить VMID, вместо предположения об успехе после короткого таймаута
- **Retry блокировки `pct create`** — временные ошибки «can't lock file ... got lock» сразу после удаления теперь повторяются несколько раз, а не роняют установку
- **Полный вывод ошибки** — упавшие шаги пайплайна теперь показывают последние 4000 символов вывода команды (раньше первые 2000), так что реальная причина не теряется под шумом прогресса `docker compose pull`
- **UI ошибки установки** — блок ошибки в диалоге установки теперь переносит длинный неразрывный текст (дайджесты образов, пути) и скроллится в своих границах вместо растягивания диалога
---
## [v1.9.0] - 2026-07-09
### 🌐 IPAM
- **Сети привязаны к серверу/ноде и рабочему пространству** — сеть IPAM теперь можно привязать к конкретному серверу/ноде Proxmox и к рабочему пространству; каждое пространство использует одну подсеть, а сеть автоматически выбирается активным пространством в мастерах создания инстанса и установки App Store, так что в типичном случае пользователю больше не нужно выбирать сеть вручную
- **Автовыбор в мастерах** — мастера развёртывания определяют целевую сеть/пул из текущего контекста и подставляют их; отсутствие привязки откатывается к ручному выбору
- Миграция: столбцы привязки сети IPAM к рабочему пространству + флаг «по умолчанию» через `backend/migrations/migrations.py`
### 🔧 Ноды
- **Домен/FQDN в поле хоста Proxmox** — форма добавления/редактирования сервера Proxmox теперь принимает доменное имя или FQDN (не только IP), которое резолвится при подключении
---
## [v1.8.1] - 2026-07-09
### 🛒 App Store
- **Umbrel как второй источник каталога** — каталог теперь мультиисточниковый: наряду с `runtipi/runtipi-appstore` можно включить `getumbrel/umbrel-apps` (~380 приложений). Источники настраиваются через `APPSTORE_SOURCES` (через запятую, по умолчанию `runtipi`); id приложений Umbrel неймспейсятся (`umbrel-<id>`), чтобы каталоги не конфликтовали
- **Compose-адаптер Umbrel** — установка понимает манифесты Umbrel: порт контейнера берётся из сервиса `app_proxy` (`APP_PORT`), runtime-сервисы Umbrel (`app_proxy`, `tor`) вырезаются, повисшие `depends_on` очищаются, а порт основного сервиса публикуется на хосте LXC, чтобы health-check прошёл; поведение Runtipi не меняется
- **Иконки Umbrel** — иконки берутся из отдельного репозитория `getumbrel/umbrel-apps-gallery` (`<app-id>/icon.svg`) при синке и кэшируются как логотипы Runtipi; сбой загрузки галереи никогда не роняет синк каталога
- **Мультиисточниковый синк** — каждый источник синкается независимо (сетевой сбой одного не отменяет другие), а проход *исчез → недоступен* ограничен своим источником, так что синк Runtipi никогда не трогает строки Umbrel
- **Фильтр источника и бейдж (UI)** — App Store получает фильтр по источнику и бейдж источника на карточке, показываемые только когда включено больше одного источника; метаданные каталога теперь отдают доступные `sources`
- **Кастомный порт установки** — мастер установки принимает произвольный порт хоста (валидация 1..65535), переопределяющий дефолт каталога
- **Мгновенная чистка инстанса** — удаление приложения сразу мягко удаляет связанный VM-инстанс для отзывчивости UI
- **Модель данных и настройки** — столбец `catalog_apps.source` (миграция 34); новые env-настройки `APPSTORE_SOURCES`, `UMBREL_APPSTORE_REPO`, `UMBREL_APPSTORE_REF`, `UMBREL_APPSTORE_GALLERY_REPO`
---
## [v1.8.0] - 2026-07-07
### 🛒 App Store
- **Каталог self-hosted приложений с установкой в один клик** — новый модуль App Store: каждое приложение разворачивается в собственном **unprivileged LXC**-контейнере с **Docker Compose** (1 приложение = 1 LXC). Каталог импортируется из репозитория `runtipi/runtipi-appstore` (~265 приложений проходят фильтр)
- **Catalog Service** — импорт метаданных, логотипов, описаний и compose-файлов в новую таблицу `catalog_apps`; идемпотентная синхронизация (кнопка *Обновить каталог* + автоматически раз в 24 ч через APScheduler); битый `config.json` пропускается, не роняя весь синк; флаг `update_available` вычисляется по `tipi_version`
- **Пайплайн установки** — клон золотого LXC-шаблона → `pct push` compose/`.env` → `docker compose up -d` → HTTP health-check, с **живым журналом шагов по WebSocket**, динамическим рендером `form_fields` (text/password/email/number/boolean/fqdn), автогенерацией `random`-секретов и хранением env в зашифрованном виде (Fernet); сгенерированные учётные данные показываются один раз
- **Управление жизненным циклом** — start / stop / restart / логи (`docker compose logs`) / удаление, плюс фоновая **реконсиляция состояния** каждые 60 с (контейнеры, удалённые мимо PVEmanager, помечаются `orphaned`)
- **Обновление и откат** — при обновлении создаётся pre-update снапшот Proxmox, затем `docker compose pull && up -d` с health-check; откат восстанавливает снапшот (вместе с данными); хранится только последний pre-update снапшот
- **UI** — сетка каталога (поиск, категории), страница приложения с дисклеймером об источнике и превью compose, мастер установки с расширенными параметрами LXC и живым прогрессом, экран управления **Мои приложения**; локали RU/EN; атрибуция *Powered by Runtipi appstore*
- **Безопасность** — только unprivileged LXC; `pct exec` / `pct push` через SSH с доставкой файлов через base64 и экранированием значений `.env` (без shell-инъекций); новые права RBAC `app:view` / `app:install` / `app:manage`
- **Модель данных и настройки** — таблицы `catalog_apps`, `installed_apps`, `app_operations` (миграции 31–32); новые env-настройки `APPSTORE_GOLDEN_TEMPLATE`, `RUNTIPI_APPSTORE_REF`, `APPSTORE_DATA_DIR`, `CATALOG_SYNC_INTERVAL_HOURS`, `APPSTORE_HOST_ARCH`
- **Документация** — `docs/golden-template.md` (подготовка золотого LXC-шаблона) и `docs/appstore-poc.md` / `appstore-catalog.md` / `appstore-engine.md`
---
## [v1.7.0] - 2026-06-26
### 🎯 Квоты

View File

@@ -1,3 +1,11 @@
## Commit message language
Git commit messages (`git commit -m`) must always be written in English, regardless of the language used elsewhere in the conversation.
## No co-author trailer
Do not add a `Co-Authored-By:` trailer (or any co-authorship line) to commit messages in this project.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

180
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,180 @@
# Contributing to PVEmanager
Thank you for considering contributing to PVEmanager! This guide will help you get started.
## Prerequisites
| Tool | Version |
|---|---|
| Docker & Docker Compose | 24.x+ / v2.x+ |
| Node.js | 20+ |
| Python | 3.12+ |
| Git | 2.x+ |
You also need access to at least one Proxmox VE host (7.x / 8.x / 9.x) for integration testing.
## Getting Started
```bash
# Clone the repo
git clone https://git.tzim.uz/markmorado/pvemanager.git
cd pvemanager
# Copy environment files
cp .env.example .env
cp backend/.env.example backend/.env
# Start everything with Docker
docker compose up -d
# Or use dev mode with Vite HMR for frontend:
docker compose -f compose.yml -f compose.dev.yml up -d frontend
```
The panel is available at `http://localhost:3001` (default login: `admin` / `admin123`).
### Frontend (local development)
```bash
cd frontend
npm install
npm run dev # Vite dev server with HMR
npm run lint # ESLint
npm run build:check # TypeScript check + build
```
### Backend (local development)
```bash
cd backend
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements-dev.txt
pytest # run test suite
```
## Project Structure
```
pvemanager/
├── backend/ # Python 3.12 + FastAPI + SQLAlchemy
│ ├── app/ # Application code
│ ├── alembic/ # Database migration config
│ ├── migrations/ # Alembic migration scripts
│ └── tests/ # pytest tests
├── frontend/ # React 19 + TypeScript + Vite 8
│ └── src/
│ ├── features/ # Feature modules
│ └── lib/ # Shared utilities
├── nginx/ # Reverse proxy config
├── systemd/ # Systemd service files
└── compose.yml # Docker Compose (production)
```
## Branch Strategy
- `main` is the primary branch. All pull requests target `main`.
- Use descriptive branch names: `feat/ssh-key-rotation`, `fix/vnc-reconnect`, `i18n/missing-keys`.
## Commit Messages
We use [Conventional Commits](https://www.conventionalcommits.org/). Write commit messages **in English**.
Format: `<type>(<optional scope>): <description>`
| Type | Use for |
|---|---|
| `feat` | New features |
| `fix` | Bug fixes |
| `docs` | Documentation changes |
| `i18n` | Translations |
| `refactor` | Code changes that neither fix bugs nor add features |
| `test` | Adding or updating tests |
| `scripts` | Shell scripts, tooling |
| `chore` | Maintenance, dependencies |
Examples:
```
feat(frontend): add bulk snapshot dialog
fix: prevent SSL rollback by using container state
i18n: translate dashboard and action dialogs
docs: release notes and version bump for v1.16.1
```
## Code Style
### Backend (Python)
- Follow PEP 8.
- Use type hints for function signatures.
- Keep imports sorted: stdlib, third-party, local.
### Frontend (TypeScript / React)
- Run `npm run lint` before committing.
- Use functional components with hooks.
- UI components: [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS v4.
- State management: Zustand for global state, TanStack Query for server state.
## Internationalization (i18n)
The UI supports Russian and English. Translation files live under `frontend/src/` and use `i18next`.
- Every user-facing string must have a translation key.
- Add keys to both `en` and `ru` translation files.
- Use the `useTranslation()` hook, not hardcoded strings.
## Testing
### Backend
```bash
cd backend
pytest # all tests
pytest -m smoke # smoke tests only
pytest -m unit # unit tests only
pytest -m integration # integration tests
pytest --cov # with coverage
```
### Frontend
```bash
cd frontend
npm run lint # ESLint checks
npm run build:check # TypeScript type checking + build
```
## Database Migrations
We use Alembic for database schema changes.
```bash
cd backend
alembic revision --autogenerate -m "describe your change"
alembic upgrade head
```
Always test migrations both up and down before submitting.
## Pull Request Process
1. Fork the repo and create your branch from `main`.
2. Make your changes, following the code style and commit conventions above.
3. Add or update tests if applicable.
4. Update translations if you added user-facing strings.
5. Run the full test suite and linter.
6. Open a pull request with a clear description of what and why.
### PR Checklist
- [ ] Commit messages follow Conventional Commits (in English)
- [ ] Linter passes (`npm run lint` / PEP 8)
- [ ] Tests pass (`pytest` / `npm run build:check`)
- [ ] New strings have i18n keys in both `en` and `ru`
- [ ] Database migration included (if schema changed)
- [ ] No secrets or credentials in the diff
## Security
If you discover a security vulnerability, **do not** open a public issue. Instead, refer to [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.7.0-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.16.1-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)
@@ -28,21 +28,31 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
- **Snapshot management** — create, rollback, delete snapshots with an async queue system
- **OS templates** — deploy VMs from templates; cross-node replication handled automatically
- **Cloud image catalog** — browse, download, and convert OS images from cloud repositories (default Proxmox mirrors and custom sources); auto-convert to VM templates with architecture-aware filtering
- **ISO eject/mount** — attach or eject an ISO on a running VM, with a boot-order switch (boot from disk or from the ISO) applied via a graceful ACPI-then-forceStop hybrid reboot
- **CT template deployment** — create LXC containers directly from Proxmox CT template files (`.tar.zst`, `.tar.gz`) with full IPAM and SSH key integration
- **Unified Create Instance Wizard** — multi-step wizard supporting both QEMU VM templates and LXC CT templates in a single flow
- **SDN support** — manage Software-Defined Networking zones, VNets, and subnets; edit zones and VNets; auto-create IPAM networks for subnets
- **Node networking** — manage node-level network interfaces (bridges, bonds, VLANs); apply/revert pending config
- **Networks page** — unified UI for SDN and node interface management
### App Store
- **Self-hosted app catalog** — one-click install of self-hosted apps, each into its own unprivileged LXC running Docker Compose (1 app = 1 LXC); multi-source catalog — `runtipi/runtipi-appstore` (~265 apps) plus optional `getumbrel/umbrel-apps` (~380 apps), selectable via `APPSTORE_SOURCES`
- **Install wizard** — dynamic app parameters (form fields), advanced LXC resources, auto-generated secrets, live install progress over WebSocket, and one-time credential display
- **My Apps lifecycle** — start / stop / restart / logs / delete, with background reconciliation (orphan detection) and per-app status
- **Update & Rollback** — snapshot-backed updates (`docker compose pull`) with health-check and one-click rollback to the pre-update snapshot
- **Global operations tray** — minimize a running install or golden-template build into a global tray that survives page navigation and tab reload; click a chip to resume the dialog where you left off
- **Secure by design** — unprivileged LXC only, Fernet-encrypted secrets, injection-safe `pct exec`/`pct push`, RBAC `app:view` / `app:install` / `app:manage`
### Access & Console
- **VNC console** — in-browser console via noVNC, no client software required
- **xterm.js terminal** — full interactive shell directly in the browser for LXC containers
- **Remote commands** — execute bash commands via QEMU Guest Agent
- **SSH Keys Management** — personal SSH key library per user; keys selectable when deploying VMs/LXC; admin can manage keys for any user
- **RBAC v2** — granular role-based access control with per-resource permissions
- **RBAC v2** — granular role-based access control with per-resource permissions; routes are guarded server- and client-side, not just hidden from the sidebar
- **Permission-free profile page** — account settings (profile, password, 2FA, SSH keys) live at `/profile` and never require a panel-settings permission, so restricting `setting:view` can't lock a user out of their own account
- **Server assignment** — directly assign specific Proxmox servers to users with workspace-conflict validation
- **VM/LXC ownership** — assign an owner user to any VM or LXC container
- **Per-user quotas** — limit each user's number of instances and total vCPU, RAM and disk; enforced at deploy time (HTTP 429), with admin management and self-service usage view
- **VM/LXC ownership** — assign an owner user to any VM or LXC container; non-privileged users see only their own instances everywhere, including the dashboard and per-node resource views; owner is set automatically on create, with a migration to backfill pre-existing instances
- **Per-user quotas** — limit each user's number of instances and total vCPU, RAM and disk; enforced at deploy time (HTTP 429); the Create Instance entry point itself is disabled once a quota has no headroom left, naming which limit is full
- **Session management** — active session list, remote session revocation, login protection
- **Smart login redirect** — users are automatically routed to the first page they have access to
@@ -53,6 +63,7 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
- **Audit log** — full action log with user, timestamp, and details
### Other
- **Command palette** — `Ctrl+K` palette with search over a unified navigation registry for keyboard-driven jumps to any page
- **IPAM** — IP Address Management with allocation history and orphan detection
- **Multilingual** — Russian and English interface; language preference is per-user account
- **Encrypted credentials** — sensitive fields stored encrypted in the database
@@ -82,6 +93,16 @@ docker compose up -d
> **Important:** Change the default password immediately after first login.
### One-command bootstrap
`bootstrap.sh` installs the panel with a single command, on either a plain Debian/Ubuntu host or directly on a Proxmox VE host — on a PVE host it provisions a Debian 12 LXC, installs the panel inside it, and registers that host as the panel's first node automatically (no Proxmox password is ever transmitted):
```bash
git clone https://git.tzim.uz/markmorado/pvemanager.git
cd pvemanager
bash bootstrap.sh
```
---
## Requirements
@@ -144,16 +165,14 @@ Configured via the web interface — **Settings → Notifications**:
|---|---|
| [WIKI.md](WIKI.md) | Full user and administrator guide |
| [CHANGELOG.md](CHANGELOG.md) | Version history and release notes |
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute — setup, code style, PR process |
| [SECURITY.md](SECURITY.md) | Security policy and vulnerability reporting |
---
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Commit your changes: `git commit -m 'feat: add my feature'`
4. Push: `git push origin feature/my-feature`
5. Open a Pull Request
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, code style, commit conventions, and the PR process.
---
@@ -167,6 +186,6 @@ Configured via the web interface — **Settings → Notifications**:
Made with ❤️ for Proxmox users
[Issues](https://git.tzim.uz/markmorado/pvemanager/issues) · [Changelog](CHANGELOG.md) · [Wiki](WIKI.md)
[Issues](https://git.tzim.uz/markmorado/pvemanager/issues) · [Changelog](CHANGELOG.md) · [Wiki](WIKI.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
</div>

View File

@@ -4,7 +4,7 @@
**Современная веб-панель для управления серверами Proxmox, виртуальными машинами и контейнерами LXC**
[![Версия](https://img.shields.io/badge/версия-1.7.0-blue?style=flat-square)](CHANGELOG.md)
[![Версия](https://img.shields.io/badge/версия-1.16.1-blue?style=flat-square)](CHANGELOG.md)
[![Лицензия](https://img.shields.io/badge/лицензия-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-готов-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)
@@ -28,19 +28,31 @@ PVEmanager — это самостоятельно размещаемая веб
- **Управление снимками** — создавайте, откатывайте, удаляйте снимки с помощью асинхронной системы очередей
- **Шаблоны ОС** — развертывайте ВМ из шаблонов; автоматическая репликация между узлами
- **Каталог облачных образов** — просмотр и скачивание образов ОС из облачных репозиториев (стандартные зеркала Proxmox и кастомные источники); автоматическая конвертация в шаблоны VM с фильтрацией по архитектуре
- **Извлечение/монтирование ISO** — подключайте или извлекайте ISO на работающей ВМ, с переключателем порядка загрузки (с диска или с ISO), применяемым через гибридную перезагрузку — штатное ACPI-выключение с откатом на принудительную остановку
- **Развёртывание CT-шаблонов** — создавайте LXC-контейнеры напрямую из CT-файлов Proxmox (`.tar.zst`, `.tar.gz`) с интеграцией IPAM и SSH-ключей
- **Единый мастер создания инстансов** — многошаговый мастер для создания как VM-шаблонов QEMU, так и LXC CT-шаблонов
- **Поддержка SDN** — управляйте зонами Software-Defined Networking, VNet и подсетями; редактируйте зоны и VNet; автоматическое создание сетей IPAM для подсетей
- **Сетевые интерфейсы узлов** — управляйте сетевыми интерфейсами на уровне узла (мосты, связки, VLAN); применяйте/откатывайте ожидающую конфигурацию
- **Страница сетей** — унифицированный интерфейс для управления SDN и сетевыми интерфейсами узлов
### App Store
- **Каталог self-hosted приложений** — установка в один клик, каждое приложение — в собственном unprivileged LXC с Docker Compose (1 приложение = 1 LXC); мультиисточниковый каталог — `runtipi/runtipi-appstore` (~265 приложений) плюс опциональный `getumbrel/umbrel-apps` (~380 приложений), выбор через `APPSTORE_SOURCES`
- **Мастер установки** — динамические параметры приложения (form_fields), расширенные ресурсы LXC, автогенерация секретов, живой прогресс установки по WebSocket и показ учётных данных один раз
- **Жизненный цикл в «Мои приложения»** — start / stop / restart / логи / удаление, с фоновой реконсиляцией (детект orphaned) и статусом по каждому приложению
- **Обновление и откат** — обновления на основе снапшотов (`docker compose pull`) с health-check и откатом в один клик к pre-update снапшоту
- **Глобальный трей операций** — сверните идущую установку или сборку золотого шаблона в глобальный трей, который переживает навигацию между страницами и перезагрузку вкладки; клик по плашке разворачивает диалог с того места, где вы остановились
- **Безопасность by design** — только unprivileged LXC, шифрование секретов (Fernet), защита `pct exec`/`pct push` от инъекций, RBAC `app:view` / `app:install` / `app:manage`
### Доступ и консоль
- **Консоль VNC** — консоль в браузере через noVNC, не требуется клиентское ПО
- **Терминал xterm.js** — полностью интерактивный shell непосредственно в браузере для контейнеров LXC
- **Удаленные команды** — выполняйте команды bash через QEMU Guest Agent- **Управление SSH-ключами** — личная библиотека SSH-ключей для каждого пользователя; ключи можно выбирать при развёртывании VM/LXC; админ может управлять ключами любого пользователя- **RBAC v2** — детальный контроль доступа на основе ролей с разрешениями для каждого ресурса
- **Удаленные команды** — выполняйте команды bash через QEMU Guest Agent
- **Управление SSH-ключами** — личная библиотека SSH-ключей для каждого пользователя; ключи можно выбирать при развёртывании VM/LXC; админ может управлять ключами любого пользователя
- **RBAC v2** — детальный контроль доступа на основе ролей с разрешениями для каждого ресурса; маршруты защищены и на бэкенде, и на фронтенде, а не просто скрыты из сайдбара
- **Страница профиля без разрешений** — настройки аккаунта (профиль, пароль, 2FA, SSH-ключи) находятся на странице `/profile` и не требуют никакого разрешения на настройки панели, так что ограничение `setting:view` не может заблокировать пользователю доступ к собственному аккаунту
- **Назначение серверов** — напрямую назначайте конкретные серверы Proxmox пользователям с проверкой конфликтов рабочих областей
- **Владелец ВМ/LXC** — назначайте пользователя-владельца любой ВМ или контейнеру LXC
- **Квоты на пользователя** — ограничение количества инстансов и суммарных vCPU, RAM и диска для каждого пользователя; проверка при развёртывании (HTTP 429), управление администратором и просмотр собственного использования
- **Владелец ВМ/LXC** — назначайте пользователя-владельца любой ВМ или контейнеру LXC; непривилегированные пользователи везде видят только свои инстансы, включая дашборд и ресурсы по ноде; владелец теперь проставляется автоматически при создании, а для уже существующих инстансов есть миграция для бэкфилла
- **Квоты на пользователя** — ограничение количества инстансов и суммарных vCPU, RAM и диска для каждого пользователя; проверка при развёртывании (HTTP 429); сама кнопка «Создать инстанс» отключается, когда квота исчерпана, с указанием какого именно лимита не хватает
- **Управление сессиями** — список активных сессий, отзыв удаленных сессий, защита входа
- **Умное перенаправление входа** — пользователи автоматически маршрутизируются на первую страницу, к которой у них есть доступ
@@ -51,6 +63,7 @@ PVEmanager — это самостоятельно размещаемая веб
- **Журнал аудита** — полный журнал действий с пользователем, временем и деталями
### Прочее
- **Командная палитра** — палитра по `Ctrl+K` с поиском по единому реестру навигации для перехода на любую страницу с клавиатуры
- **IPAM** — управление IP-адресами с историей выделения и обнаружением сиротских адресов
- **Многоязычность** — интерфейс на русском и английском языках; язык задаётся отдельно для каждого аккаунта в профиле
- **Зашифрованные учетные данные** — чувствительные поля хранятся зашифрованными в базе данных
@@ -80,6 +93,16 @@ docker compose up -d
> **Важно:** Измените пароль по умолчанию сразу после первого входа.
### Установка одной командой
`bootstrap.sh` устанавливает панель одной командой — как на обычном хосте Debian/Ubuntu, так и напрямую на хосте Proxmox VE: на хосте PVE скрипт сам создаёт LXC на Debian 12, устанавливает панель внутри и регистрирует этот хост как первую ноду панели (пароль от Proxmox никуда не передаётся):
```bash
git clone https://git.tzim.uz/markmorado/pvemanager.git
cd pvemanager
bash bootstrap.sh
```
---
## Требования
@@ -142,16 +165,14 @@ GITHUB_TOKEN= # опционально, для провер
|---|---|
| [WIKI.md](WIKI.md) | Полное руководство пользователя и администратора |
| [CHANGELOG.md](CHANGELOG.md) | История версий и примечания к выпускам |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Как внести вклад — настройка, стиль кода, процесс PR |
| [SECURITY.md](SECURITY.md) | Политика безопасности и сообщение об уязвимостях |
---
## Участие в разработке
1. Сделайте форк репозитория
2. Создайте ветку функции: `git checkout -b feature/my-feature`
3. Зафиксируйте изменения: `git commit -m 'feat: добавьте мою функцию'`
4. Отправьте: `git push origin feature/my-feature`
5. Откройте Pull Request
Мы рады вашему вкладу! См. [CONTRIBUTING.md](CONTRIBUTING.md) — там описаны настройка окружения, стиль кода, формат коммитов и процесс PR.
---
@@ -165,6 +186,6 @@ GITHUB_TOKEN= # опционально, для провер
Сделано с ❤️ для пользователей Proxmox
[Проблемы](https://git.tzim.uz/markmorado/pvemanager/issues) · [История изменений](CHANGELOG.md) · [Вики](WIKI.md)
[Проблемы](https://git.tzim.uz/markmorado/pvemanager/issues) · [История изменений](CHANGELOG.md) · [Вики](WIKI.md) · [Участие](CONTRIBUTING.md) · [Безопасность](SECURITY.md)
</div>

View File

@@ -2,20 +2,60 @@
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
|---|---|
| 1.16.x (latest) | :white_check_mark: |
| < 1.16 | :x: |
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
Only the latest minor release receives security patches. We recommend always running the most recent version.
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
**Do not open a public issue for security vulnerabilities.**
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
Please report them privately via email: **rasulovd9309@gmail.com**
Include as much detail as possible:
- Description of the vulnerability
- Steps to reproduce
- Affected component (backend, frontend, nginx, Docker config)
- Potential impact
- Suggested fix (if any)
### What to Expect
- **Acknowledgement** within 48 hours.
- **Status update** within 7 days with an initial assessment.
- If confirmed, a fix will be developed and released as a patch version. You will be credited in the release notes (unless you prefer to remain anonymous).
- If declined, we will explain why.
## Scope
The following are in scope:
- Authentication and authorization bypass
- SQL injection, command injection, SSRF
- Credential or secret exposure
- Cross-site scripting (XSS) and CSRF
- Proxmox API token or session leakage
- Container escape or privilege escalation
- Insecure default configurations
Out of scope:
- Vulnerabilities in Proxmox VE itself (report to [Proxmox](https://www.proxmox.com/en/about/security))
- Denial-of-service attacks against self-hosted instances
- Issues requiring physical access to the host
## Security Design
PVEmanager applies the following security measures:
- **Encrypted credentials** — Proxmox API tokens and sensitive settings are Fernet-encrypted at rest in PostgreSQL.
- **Bcrypt passwords** — user passwords are hashed with bcrypt, never stored in plaintext.
- **JWT sessions** — short-lived tokens with server-side revocation support.
- **2FA** — optional TOTP-based two-factor authentication per user.
- **RBAC** — granular role-based access control enforced on both server and client side.
- **Unprivileged LXC** — App Store containers run unprivileged by design.
- **Injection-safe exec** — `pct exec` / `pct push` calls are parameterized to prevent shell injection.

View File

@@ -1 +1 @@
1.7.0
1.16.1

609
WIKI.md
View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.5.2-1
> Complete guide for installation, configuration and usage of PVEmanager v1.16.0
---
@@ -15,27 +15,28 @@
7. [OS Templates](#-os-templates)
8. [Images — Cloud Image Catalog](#-images--cloud-image-catalog)
9. [LXC CT Template Deployment](#-lxc-ct-template-deployment)
10. [Proxmox Clusters](#-proxmox-clusters)
11. [Snapshots](#-snapshots)
12. [Backups](#-backups)
13. [IPAM](#-ipam)
14. [Remote Command Execution](#-remote-command-execution)
15. [Monitoring](#-monitoring)
16. [Security (RBAC v2)](#-security)
17. [Localization](#-localization)
18. [Settings](#-settings)
19. [SSH Keys Management](#-ssh-keys-management)
20. [Networks (SDN & Node Interfaces)](#node-network-interfaces)
21. [pve CLI Tool](#-pve-cli-tool)
22. [API Reference](#-api-reference)
23. [Workspaces](#-workspaces)
24. [User → Server Assignment](#-user--server-assignment)
25. [VM / LXC Ownership](#-vm--lxc-ownership)
26. [Quotas](#-quotas)
27. [Logs & Audit](#-logs--audit)
28. [Deployment Guide](#-installation-and-deployment)
29. [Troubleshooting](#-troubleshooting)
30. [FAQ](#-faq)
10. [App Store](#-app-store)
11. [Proxmox Clusters](#-proxmox-clusters)
12. [Snapshots](#-snapshots)
13. [Backups](#-backups)
14. [IPAM](#-ipam)
15. [Remote Command Execution](#-remote-command-execution)
16. [Monitoring](#-monitoring)
17. [Security (RBAC v2)](#-security)
18. [Localization](#-localization)
19. [Settings](#-settings)
20. [SSH Keys Management](#-ssh-keys-management)
21. [Networks (SDN & Node Interfaces)](#node-network-interfaces)
22. [pve CLI Tool](#-pve-cli-tool)
23. [API Reference](#-api-reference)
24. [Workspaces](#-workspaces)
25. [User → Server Assignment](#-user--server-assignment)
26. [VM / LXC Ownership](#-vm--lxc-ownership)
27. [Quotas](#-quotas)
28. [Logs & Audit](#-logs--audit)
29. [Deployment Guide](#-installation-and-deployment)
30. [Troubleshooting](#-troubleshooting)
31. [FAQ](#-faq)
---
@@ -101,6 +102,21 @@ docker compose up -d
# Specify domain and email for Let's Encrypt
```
#### 4. One-command bootstrap (`bootstrap.sh`)
A single script that installs the panel on either a plain Debian/Ubuntu host or directly on a Proxmox VE host — it detects which by checking for `/etc/pve`:
```bash
git clone https://git.tzim.uz/markmorado/pvemanager.git
cd pvemanager
bash bootstrap.sh
```
- **Plain Debian/Ubuntu host** — installs Docker and `git`/`curl`/`openssl`/`jq` if missing, clones the repo into `PVEMANAGER_DIR` (default `/opt/pvemanager`) and hands off to `deploy.sh --standalone`.
- **Proxmox VE host** — creates a Debian 12 LXC (DHCP on `vmbr0`, `nesting=1,keyctl=1` for Docker inside), installs the panel inside it via the same flow, then — once the panel answers `GET /health` — creates a `root@pam!pvemanager` API token locally on the PVE host (`pvesh create ... --privsep 0`) and registers that host as the panel's first node. **No Proxmox password is ever transmitted** to or through the panel.
- Configuration is via environment variables (`PVEMANAGER_REPO`, `PVEMANAGER_VERSION`, `PVEMANAGER_DIR`, and `PVEMANAGER_LXC_*` for the container's vmid/storage/bridge/resources) — see the header of `bootstrap.sh` for the full list and defaults.
- Tolerates `apt-get update` failures from an unsubscribed PVE host's enterprise repo instead of aborting before `git`/`curl` are installed.
### Environment Variables
#### Main (`.env`)
@@ -172,10 +188,11 @@ This section summarizes key API endpoints. All require JWT authentication.
### Proxmox Servers
- `GET /api/servers` — list servers
- `POST /api/servers` — add server
- `POST /api/servers` — add server (`auto_create_token` provisions an API token from a login/password)
- `PUT /api/servers/{id}` — update server
- `DELETE /api/servers/{id}` — delete server
- `POST /api/servers/{id}/test` — test connection
- `POST /api/servers/{id}/provision-token` — migrate a password-auth server to an auto-provisioned API token
### Virtual Machines
@@ -307,8 +324,9 @@ This section summarizes key API endpoints. All require JWT authentication.
- Add multiple servers
- API Token or password authentication
- Automatic API token creation
- Server status monitoring
- Automatic API token creation, with in-place migration for existing password-auth servers
- Server status monitoring, distinguishing an unreachable node from rejected/stale credentials
- Per-node storage availability
### VM and LXC Management
@@ -393,7 +411,7 @@ Per-user resource limits enforced when deploying VMs/LXC:
### Monitoring
- Real-time CPU, RAM, Disk, Network metrics
- Historical graphs (hour, day, week, month)
- Historical graphs (hour, day, week, month) for both instances and nodes (node **Graphs** tab: CPU, IO delay, memory, load average, network, swap, root filesystem)
- Resource alert thresholds
---
@@ -475,8 +493,14 @@ Automatically tracks:
## 🖥️ VM and Container Management
### Command palette (Ctrl+K)
Press **Ctrl+K** (⌘K on macOS) anywhere in the app to open the **command palette** — a searchable list of every navigation destination. Start typing to filter, then <kbd>Enter</kbd> to jump. The palette and the sidebar are driven by the same unified navigation registry, so they always stay in sync.
### VM Actions
The per-instance row menu (⋯) exposes **Options**, **Migrate**, **Change resources**, **Create backup** and **Delete** alongside the power actions below. The menu stays open across live list refreshes.
| Action | Description | Hotkey |
|--------|-------------|--------|
| Start | Start VM | - |
@@ -485,6 +509,8 @@ Automatically tracks:
| Force Stop | Force stop | - |
| Delete | Delete VM | - |
> **Instance tabs:** for **VMs**, *Compute resources*, *CPU options*, *Disk management* and *Disk resize* live on the **Hardware** tab; **LXC** containers (which have no Hardware tab) keep those cards on the **Settings** tab.
### Bulk Operations
Select multiple VMs/containers and perform mass actions:
@@ -663,6 +689,8 @@ The **Images** module allows you to browse, download, and manage OS images from
- **Auto-convert to template** — optional automatic conversion of downloaded images to Proxmox VM templates
- **Architecture-aware filtering** — images are filtered by node platform (x86-64, aarch64); default architecture matches the selected node
- **Custom mirrors** — administrators can add, edit, and manage custom mirror sources for image discovery
- **ISO downloads** — fetch install ISOs by URL, from a saved mirror (`kind=iso`), or straight from the ISO step of the create-instance wizard. The bundled catalog stays `.qcow2`-only on purpose — install ISOs have no stable "latest" URL, so hardcoded links would rot on every point release
- **Local file upload** — upload an ISO or LXC template (`vztmpl`) from your own machine straight to node storage, for images that aren't published anywhere the panel can reach
### Accessing Images
@@ -678,6 +706,41 @@ The **Images** module allows you to browse, download, and manage OS images from
4. Click **Download** — progress is tracked in real-time
5. Once complete, the image is available in storage for VM deployment
### Uploading a Local File
1. Go to **Infrastructure** → **Images** and pick **Upload**
2. Select an ISO or `vztmpl` file and the target storage
3. The panel streams the file to the node in the background; progress is tracked the same way as a URL download
4. Once complete, the file is available in storage for VM/CT deployment
### Installing an OS from an ISO
The create-instance wizard offers a **blank VM** kind for ISO-based installs: it provisions an empty disk with the chosen ISO mounted on `ide2` and set first in the boot order, so the VM boots straight into the installer over the built-in noVNC console. Selecting a Windows guest automatically presets OVMF firmware, a SATA disk and an e1000 NIC, since the installer has no virtio drivers loaded yet.
### Ejecting and Mounting an ISO
From a VM's detail page, the ISO dialog shows every ISO already on the node's storage — including ones that predate the node joining the panel — and the image currently mounted, if any:
- **Mount** — pick an ISO from storage; optionally check **"boot from this ISO"** to put the CD-ROM drive first in the boot order.
- **Eject** (renamed from "Detach") — remove the mounted ISO; optionally check **"boot from disk"** to put the primary disk first in the boot order.
Either action that changes boot order applies it via a **hybrid reboot**: a graceful ACPI shutdown with a `forceStop` fallback, then start. This avoids the guest-ping timeout that a plain restart hits on a freshly installed OS with no QEMU Guest Agent running yet.
### GitHub Mirror with QEMU Guest Agent
The bundled amd64 image catalog points to a **GitHub mirror** that contains cloud images pre-patched with `qemu-guest-agent`. This means:
- Live per-VM metrics (CPU, memory, disk fill, I/O rates) work immediately after deploy — no post-install agent setup needed
- Images include a **sha256 checksum** for integrity verification; if the cached file no longer matches the published checksum, the panel re-downloads it automatically
To rebuild or update the mirror yourself, run the included helper script on a Linux host with QEMU installed:
```bash
bash patch-cloud-images.sh
```
It downloads the upstream `.qcow2`, installs `qemu-guest-agent` via `virt-customize`, and publishes the patched image as a GitHub Release.
### Custom Mirrors (Admin Only)
Administrators can configure custom image repositories:
@@ -767,6 +830,101 @@ Containers created from CT templates support reinstall:
---
## 🛒 App Store
Catalog of self-hosted applications with one-click install. Each app runs in its own **unprivileged LXC** container with **Docker Compose** (1 app = 1 LXC). The catalog is **multi-source**: [`runtipi/runtipi-appstore`](https://github.com/runtipi/runtipi-appstore) (~265 apps) and, optionally, [`getumbrel/umbrel-apps`](https://github.com/getumbrel/umbrel-apps) (~380 apps). Sources are selected via `APPSTORE_SOURCES`; Umbrel app ids are namespaced (`umbrel-<id>`) to avoid collisions.
### Prerequisites
- A **golden LXC template** (Debian 12 + Docker + Compose) per Proxmox server. Build it from **App Store → золотой шаблон** (`POST /api/appstore/golden-template`, picks server/node/storage) — this automates the manual procedure in [docs/golden-template.md](docs/golden-template.md). Template and rootfs storage are picked from dropdowns populated with the selected server/node's actual storages (filtered by `vztmpl`/`rootdir` content), defaulting to `local`/`local-lvm` when available. The resulting vztmpl volid is stored **per `server_id`** (a template built on server A lives on A's storage only and is never used to install on server B); `APPSTORE_GOLDEN_TEMPLATE` is just the last-resort fallback for servers that never got their own build.
- **SSH access** from the panel to the Proxmox node (used for `pct exec` / `pct push`).
- `FERNET_KEY` set (encrypts app secrets at rest).
### Environment settings
| Variable | Default | Purpose |
|---|---|---|
| `APPSTORE_GOLDEN_TEMPLATE` | — | golden vztmpl volid fallback, used only if a server has no per-server template saved |
| `APPSTORE_NAMESERVER` | `1.1.1.1 8.8.8.8` | DNS explicitly set on App Store LXCs (and the golden-template build CT) — without it, the container inherits the node's resolver, which can be unreachable from the LXC's network (e.g. a Tailscale-only DNS on the host) and break image pulls |
| `APPSTORE_SOURCES` | `runtipi` | active catalog sources, comma-separated (`runtipi`, `umbrel`) |
| `RUNTIPI_APPSTORE_REPO` | `runtipi/runtipi-appstore` | Runtipi catalog source |
| `RUNTIPI_APPSTORE_REF` | `master` | branch/tag/commit (pin recommended) |
| `UMBREL_APPSTORE_REPO` | `getumbrel/umbrel-apps` | Umbrel catalog source (used when `umbrel` is enabled) |
| `UMBREL_APPSTORE_REF` | `master` | Umbrel branch/tag/commit |
| `UMBREL_APPSTORE_GALLERY_CDN` | `https://getumbrel.github.io/umbrel-apps-gallery` | Umbrel icons CDN (`<app-id>/icon.svg`, fetched per-file in parallel) |
| `APPSTORE_DATA_DIR` | `/app/data/appstore` | logo cache (mounted volume) |
| `CATALOG_SYNC_INTERVAL_HOURS` | `24` | auto catalog sync interval |
| `APPSTORE_HOST_ARCH` | `amd64` | target node arch (marks unsupported apps) |
### Catalog
- **App Store** screen: card grid with search and category filters; **Refresh catalog** button + last-sync timestamp. When more than one source is enabled, a **source filter** and a per-card **source badge** (Runtipi / Umbrel) appear.
- Automatic sync every 24 h; a broken app entry is skipped without failing the whole sync. Each source syncs independently and the *disappeared → unavailable* pass is scoped per source.
- Umbrel apps: metadata comes from `umbrel-app.yml`, icons from the gallery repo; on install the Umbrel `app_proxy`/`tor` runtime services are stripped and the container port (`app_proxy → APP_PORT`) is published on the LXC host.
- App page: description, version, source link, install disclaimer, and a compose preview.
### Install
Pipeline (each step is journaled and streamed over WebSocket):
1. Clone the golden LXC template (default 2 vCPU / 2048 MB / 8 GB, DHCP or advanced overrides).
2. Start and wait for IP.
3. `pct push` `docker-compose.yml` + `.env` (form answers + generated `random` secrets) + any bundled `data_files`.
4. `docker compose up -d`.
5. HTTP health-check.
6. Show the app URL and one-time credentials.
A failed step sets status `error` with a step log; **Retry** does not recreate the LXC (idempotent by VMID) and reuses the original install parameters (saved in `installed_apps.install_params`), **Delete** removes the container and the record.
**Reliability notes:**
- Files bundled with a catalog app (`data_files`) are delivered to the container before `docker compose up`; Umbrel `APP_PASSWORD` / `default_credentials` are honored.
- Bind-mount host directories (`volumes:` in the app's compose, both `${APP_DATA_DIR}/...` and relative paths) are pre-created with permissive ownership before `up` — otherwise Docker auto-creates them as root and non-root container users (e.g. mariadb's `mysql`) get `Permission denied`.
- The VMID search skips already-occupied records — `nextid` does not reserve the number, so a retry could otherwise grab another container.
- `pct create` retries a few times on a transient config-lock error (common right after deleting the same VMID, while the node is still wiping the old disk).
- **Delete** now waits for Proxmox to actually confirm the destroy task finished (up to 30 min, covers slow disk wipes) before marking the app removed — otherwise a VMID could be reused while still busy on the node.
- `.env` values containing `#` are quoted (the dotenv parser would otherwise trim them as an inline comment); a port chosen explicitly in the wizard (including `80`) is published as-is.
### Minimized operations tray
Long-running App Store operations — an app **install** and a **golden-template build** — can be minimized instead of keeping the dialog open:
- Minimized operations live in a **global store** (persisted to `localStorage`) and are rendered as a **tray** in the app layout, so a chip **survives page navigation and a full tab reload**. The tray polls each operation's progress itself (REST, every 3 s), independently of the dialog.
- **Clicking a chip** returns to the operation's page and reopens the dialog — an install resumes in the wizard, a template build reopens the golden-template dialog with the server preselected.
- **Minimize** adds the operation to the tray and closes the dialog; the chip's ✕ only removes the chip (the operation keeps running on the server). Chips for operations that no longer exist (a `404` after reload) are cleared automatically.
### My Apps
Manage installed apps: **Start / Stop / Restart / Logs / Delete**, live status, and a clickable `IP:port`.
- **Reconciliation** runs every 60 s and on screen open: an LXC removed outside PVEmanager is flagged `orphaned`.
- **Update**: takes a pre-update Proxmox snapshot → `docker compose pull && up -d` → health-check; keeps only the last pre-update snapshot.
- **Rollback**: restores the snapshot (⚠️ also reverts app **data** to the snapshot moment) and reverts the version in the database.
### Permissions
`app:view` (catalog + My Apps), `app:install` (install), `app:manage` (sync, lifecycle, update, rollback, delete). Admins bypass all checks.
### REST API
```
GET /api/appstore/catalog # list (q, category, source)
GET /api/appstore/catalog/{app_id} # detail
GET /api/appstore/catalog/{app_id}/logo # logo (public)
POST /api/appstore/catalog/sync # manual sync (admin)
POST /api/appstore/apps/install # install
GET /api/appstore/apps # installed list
POST /api/appstore/apps/{id}/action/{start|stop|restart}
GET /api/appstore/apps/{id}/logs
POST /api/appstore/apps/{id}/update
POST /api/appstore/apps/{id}/rollback
POST /api/appstore/apps/reconcile
DELETE /api/appstore/apps/{id}
POST /api/appstore/golden-template # build golden vztmpl for a server
GET /api/appstore/golden-template/status?server_id=… # per-server template + last build op
```
---
## <20>🔗 Proxmox Clusters
### Overview
@@ -805,6 +963,16 @@ pvecm add 10.10.10.11 # IP of pve1
See [OS Templates - Cross-Node Template Deployment](#cross-node-template-deployment-clusters)
### Importing an Existing Cluster
If nodes were already joined into a cluster outside the panel (via `pvecm` or the Proxmox web UI), add each node to the panel as a regular standalone server first, then use **Cluster** → **Import**:
1. Pick any one of the already-added nodes — the panel reads `cluster/config/join` from it to detect the real cluster name and node list
2. Matching panel servers (by IP or hostname) are pre-selected; nodes not yet added to the panel are listed but can't be linked until you add them as servers
3. Confirm — the panel only links the matching server records to the cluster group in its own database; no `pvecm` command is run and the real cluster is not touched
This is the DB-only counterpart to **Create cluster**/**Join node**, which perform the actual `pvecm create`/`pvecm add` operations — use Import only when the nodes are already clustered for real.
---
## 📸 Snapshots
@@ -1105,92 +1273,305 @@ PVEmanager includes comprehensive security features to protect your infrastructu
### RBAC v2 — Role-Based Access Control
The new RBAC v2 system uses atomic permissions with `resource:action` format.
> **This is the section to read if RBAC feels confusing.** It explains the whole
> model end-to-end: what a permission *is*, how a request is actually checked,
> what every built-in role can do, and — crucially — how "each user sees only
> their own VMs" is a **separate** mechanism from RBAC and is easy to
> misconfigure. Backend source of truth: `backend/app/rbac/permissions.py`
> (catalogue of permissions), `backend/app/rbac/engine.py` (the check logic),
> `backend/app/rbac/migration.py` (default roles).
#### Permission Format
#### The mental model in one paragraph
A **user** has exactly one **role**. A role is just a named JSON map of
**permissions** → `true`/`false` (e.g. `{"vm:view": true, "vm:delete": false}`).
Every protected API endpoint declares the permission it needs (e.g. "you need
`vm:create`"). On each request the **Permission Engine** looks at the caller's
role map and answers yes/no. There are no per-resource ACLs and no permission
"scopes" in practice — a permission is either granted to the role or it is not.
The one apparent exception, "users only see their own instances", is **not** a
permission at all — it is a separate ownership filter described at the bottom of
this section.
#### Permission format
Every permission is an atomic string:
```
resource:action[:scope]
resource:action
```
Examples:
- `vm:view` — View virtual machines
- `server:create` — Add new Proxmox servers
- `log:export` — Export audit logs
- `role:manage` — Full role management
- **`resource`** — *what* you are acting on (`vm`, `backup`, `user`, …).
- **`action`** — *what you do* to it (`view`, `create`, `delete`, …).
#### Available Resources
Examples: `vm:view` (see VMs), `server:create` (add a Proxmox server),
`log:export` (download audit logs), `role:manage` (full role administration).
| Resource | Description |
|----------|-------------|
| `dashboard` | Dashboard access |
| `server` | Proxmox servers |
| `vm` | Virtual machines |
| `lxc` | LXC containers |
| `template` | OS templates |
| `storage` | Storage pools |
| `backup` | Backups |
| `ipam` | IP address management |
| `user` | User management |
| `role` | Role management |
| `log` | Audit logs |
| `setting` | Panel settings |
| `notification` | Notifications |
> **About `:scope`.** The permission dataclass technically supports a third
> `:scope` segment, but **no built-in permission uses it** — every real
> permission is global (`resource:action`). Ignore scope when designing roles;
> tenant isolation is handled by ownership, not by a `:own` scope. (Older
> versions of this wiki documented fictional `vms:view:own` codes — those never
> existed in the code and have been removed.)
#### Available Actions
#### How a permission check actually works (the engine)
| Action | Description |
|--------|-------------|
| `view` | Read access |
| `create` | Create new resources |
| `update` | Modify existing resources |
| `delete` | Remove resources |
| `start` | Start VM/container |
| `stop` | Stop VM/container |
| `restart` | Restart VM/container |
| `console` | Access console |
| `migrate` | Migrate between nodes |
| `manage` | Full management (implies view, update, etc.) |
| `export` | Export data (logs) |
| `execute` | Execute commands |
When an endpoint asks "does this user have `X:Y`?", `PermissionEngine.has_permission`
evaluates these rules **in order** and returns `true` on the first match:
#### Default Roles
1. **Admin bypass.** If the user is an administrator (`is_admin`, or role name
`admin`), the answer is always **yes** — admins implicitly hold every
permission and are never blocked.
2. **Direct grant.** The exact code is present and `true` in the role map
(`vm:create` is in the role → yes).
3. **Wildcard grant.** The role holds `resource:*` (e.g. `vm:*` grants *every*
`vm:` action). Wildcards are not used by the built-in roles but you may add
them to custom roles.
4. **`manage` implies common actions.** If the role holds `resource:manage`, the
engine also grants these actions on that resource: **`view`, `list`, `update`,
`start`, `stop`, `restart`**. So `vm:manage` alone lets a user view/edit/power
VMs without listing those actions separately. ⚠️ Note `manage` does **not**
imply `create` or `delete` — grant those explicitly.
| Role | Description |
|------|-------------|
| `admin` | Full access to all features |
| `moderator` | VM management, view logs, no settings |
| `user` | VPS-style access — only own instances |
| `demo` | Read-only access |
If none match, access is denied with **HTTP 403 `Permission denied: X:Y`**.
### VPS-Style User Isolation
> **Enforcement points.** Endpoints gate access via the `PermissionChecker`
> FastAPI dependency (`Depends(PermissionChecker("vm:create"))`) or an inline
> `require_permission(user, "vm:create")` call. The frontend independently hides
> sidebar items the user lacks permission for (see the UI-mapping table below),
> but the backend check is the real security boundary.
Users with `user` role have VPS-style access — they can only see and manage instances assigned to them.
#### `requires` — permission dependencies
#### How It Works
Many permissions declare prerequisites (the `requires` field in
`permissions.py`). These express logical dependencies — e.g. `vm:create`
`requires` `vm:view` + `template:view`; `vm:execute` `requires` `vm:console`.
1. **Instance Ownership**: Each VM/LXC can have an `owner_id` pointing to a user
2. **Automatic Assignment**: When a user creates a VM, they automatically become the owner
3. **Access Control**: All operations (view, start, stop, console, snapshots) check ownership
> **Important:** `requires` is **documentation / UI guidance**, not runtime
> enforcement — the engine does not auto-grant prerequisites. When you build a
> custom role, tick the prerequisites yourself, otherwise the feature may be
> half-broken (e.g. a user who can `vm:create` but lacks `vm:view` cannot see the
> VM they just created).
#### User Role Permissions
#### Full permission catalogue
```
vms:view:own — View only own instances
vms:start:own — Start own instances
vms:stop:own — Stop own instances
vms:restart:own — Restart own instances
vms:console:own — Access console of own instances
vms:snapshots:own — Manage snapshots of own instances
Grouped by resource. "Implied by `manage`" means the engine grants it for free
when the role holds `<resource>:manage` (rule 4 above).
**Dashboard**
| Permission | Unlocks |
|---|---|
| `dashboard:view` | Access the Dashboard page and overview stats |
**Proxmox Servers & Cluster** (`server`, `cluster`)
| Permission | Unlocks |
|---|---|
| `server:view` | See the server/node list and status |
| `server:create` | Add a new Proxmox server |
| `server:update` | Edit server connection settings |
| `server:delete` | Remove a Proxmox server |
| `server:manage` | Full server management incl. cluster ops (requires `server:view`) |
| `cluster:manage` | Create clusters, join/eject nodes (requires `server:view`) |
**Virtual Machines** (`vm`)
| Permission | Unlocks |
|---|---|
| `vm:view` | See VMs (subject to ownership filter — see below) |
| `vm:create` | Create VMs (requires `vm:view`, `template:view`) |
| `vm:update` | Change VM config (CPU/RAM/disk) |
| `vm:delete` | Delete VMs |
| `vm:start` / `vm:stop` / `vm:restart` | Power actions |
| `vm:console` | noVNC / xterm.js console |
| `vm:migrate` | Migrate between nodes of the same cluster |
| `vm:remote_migrate` | Migrate to a *different* registered cluster (requires `vm:migrate`) |
| `vm:execute` | Run commands via QEMU guest agent (requires `vm:console`) |
| `vm:manage` | Full VM management **and the global "see all instances" flag** (see isolation section) |
**LXC Containers** (`lxc`) — same shape as VM: `view`, `create`, `update`,
`delete`, `start`, `stop`, `restart`, `console`, `migrate`, `remote_migrate`.
(There is no separate `lxc:manage`; container listing/power is governed by the
`lxc:*` actions and the same ownership filter as VMs.)
**Templates & Images** (`template`)
| Permission | Unlocks |
|---|---|
| `template:view` | Browse OS templates |
| `template:create` / `template:update` / `template:delete` | Manage template entries |
| `template:manage` | Full template management incl. the **Images** (cloud-image download) page |
**Storage** (`storage`): `storage:view`, `storage:manage`.
**Backups** (`backup`): `backup:view`, `backup:create`, `backup:restore`, `backup:delete`, `backup:manage` (storages + scheduled jobs).
**IPAM** (`ipam`): `ipam:view`, `ipam:manage`.
**Networking** (`network`): `network:view`, `network:manage` (node interfaces + SDN zones/vnets/subnets).
**Firewall** (`firewall`): `firewall:view`, `firewall:manage` (datacenter/node/VM rules, groups, IP sets).
**Node administration** (`node`): `node:view`, `node:manage` (systemd services, APT), `node:upgrade` (apt dist-upgrade), `node:power` (reboot/shutdown the physical node).
**Resource pools** (`pool`): `pool:view`, `pool:manage`.
**PVE access** (`access`): `access:view`, `access:manage` (Proxmox auth realms & API tokens).
**App Store** (`app`): `app:view` (catalog + My Apps), `app:install`, `app:manage` (sync, update, rollback, delete).
**Scripts** (`script`): `script:view`, `script:execute`, `script:manage` (edit scripts & git sources).
**Users, Quotas & Roles** (`user`, `quota`, `role`)
| Permission | Unlocks |
|---|---|
| `user:view` | See the user list |
| `user:create` / `user:update` / `user:delete` | Manage user accounts |
| `user:manage` | Full user admin, incl. managing **other** users' SSH keys |
| `quota:view` / `quota:manage` | Read / set per-user resource quotas (requires `user:view`) |
| `role:view` | See roles & their permissions |
| `role:create` / `role:update` / `role:delete` | Manage roles |
| `role:manage` | Full role administration incl. assigning roles (requires `role:view`, `user:view`) |
**Logs** (`log`): `log:view`, `log:export`, `log:delete`.
**Settings** (`setting`): `setting:view`, `setting:update` (panel settings), `setting:manage` (security & advanced settings). These cover **panel-wide** configuration only — a user's own account (profile, password, 2FA, SSH keys, notification preferences) lives at `/profile`, is self-scoped, and needs no permission.
**Notifications** (`notification`): `notification:view`, `notification:manage`.
> **Delegated role editing.** A non-admin with `role:manage` can only grant
> permissions they **themselves already hold** (`can_assign_permission` /
> `validate_role_permissions`). This prevents privilege escalation — you cannot
> hand out access you don't have. Admins are exempt and can grant anything.
#### Which permission unlocks which sidebar page
The frontend (`frontend/src/lib/nav-items.ts`) hides a menu entry unless the
user is an admin or holds the listed permission. Items with **no** permission
are **admin-only**. The same permission also guards the route itself
(`RequirePermission` in `frontend/src/components/shared/route-guards.tsx`), so
a hidden page cannot be reached by typing its URL — denied users are redirected
to the first page they can open.
| Sidebar item | Required permission |
|---|---|
| Dashboard | `dashboard:view` |
| Instances | `vm:view` |
| Nodes | `server:view` |
| Cluster | `cluster:manage` |
| Templates | `template:view` |
| Images | `template:manage` |
| Backups | `backup:view` |
| Tasks | `vm:view` |
| App Store / My Apps | `app:view` |
| Scripts | `script:view` |
| IPAM | `ipam:view` |
| Networks | `server:manage` |
| Users | `user:view` |
| Workspaces | *(admin-only)* |
| Logs | `log:view` |
| Settings (panel-wide) | `setting:view` |
| Profile (user menu, not the sidebar) | *(none — any logged-in user)* |
#### Default (built-in) roles
Four roles are seeded/updated on every startup by
`ensure_default_roles_new_format`. They are `is_system` roles. The seeding logic
**only adds missing permission keys** — it never overwrites keys an admin has
already toggled in the UI — so your customisations to these roles survive
upgrades. New permissions introduced by a version bump are also **backfilled**
onto custom roles that already hold the matching umbrella permission
(`backfill_granular_permissions`), so a custom role built on `server:manage`
automatically gains new `node:*` / `network:*` keys instead of silently losing
access.
| Role | Intended use | What it can do |
|------|--------------|----------------|
| **admin** | Platform administrator | Everything. Holds every permission *and* the `is_admin` bypass — sees all servers, all instances (regardless of owner), all users, settings, security. |
| **moderator** | Operator without admin rights | View dashboard/servers; **create + power + console** VMs and LXC; view templates/storage/firewall/network/node/pool/backups/IPAM/users/logs; create backups; export logs; manage notifications. **No** delete, no settings changes, no user/role management. Note: without `vm:manage` a moderator is still subject to the ownership filter (see below). |
| **user** | Standard tenant (VPS-style) | View + **start/stop/restart/console** their **own** VMs and LXC; view templates/storage/firewall/network/node/pool/IPAM; manage own notifications. Their own profile, SSH keys and quota live at `/profile` and need no permission, so this role deliberately has **no** `setting:view` (that would hand out the panel-wide settings page). **No** create/delete by default, **no** dashboard. Scoped to owned instances because it lacks `vm:manage`. |
| **demo** | Read-only showcase | View-only across dashboard, servers, VMs, LXC, templates, storage, firewall, network, node, pool, IPAM. No power actions, no writes. |
> The exact JSON for each role lives in `NEW_DEFAULT_ROLES` in
> `backend/app/rbac/migration.py` — consult it if you need the byte-for-byte
> permission set.
#### Managing roles in the UI
1. Open **Users** (needs `role:view`; editing needs `role:manage` or admin).
2. Switch to the **Roles** tab → **New Role** or edit an existing one.
3. Permissions are presented grouped by category (Virtual Machines, Backups,
User Management, …). Tick the checkboxes you want.
4. Remember to also tick the **prerequisites** (`requires`) — the engine does not
auto-enable them.
5. Assign the role to a user on the user's edit dialog.
`is_system` roles (admin/moderator/user/demo) cannot be deleted, but their
permission sets can be tuned (your changes are preserved across upgrades).
#### Legacy permission format
Older releases used a dot format (`vms.view`, `proxmox.manage`). These are
mapped to the new colon format via `LEGACY_PERMISSION_MAP` and converted
**once** in the database at startup, so no runtime translation is needed. You may
still type a legacy code into a custom role — it is resolved transparently — but
new roles should use the `resource:action` form.
---
### User Isolation — "each user sees only their own instances"
This is the feature most often confused with RBAC. **It is not a permission.**
It is a separate ownership filter layered *on top of* the `vm:*`/`lxc:*`
permissions. Understanding the split is the key to configuring tenants correctly.
#### The rule
| Caller | Sees / controls |
|---|---|
| Admin, or role named `admin` | **All** instances |
| Any role holding **`vm:manage`** | **All** instances |
| Everyone else (incl. stock `user` role) | **Only** instances they **own** (`owner_id == user.id`) |
The decision is made by `can_view_all_instances` in
`backend/app/api/proxmox/_helpers.py`. Note carefully: the "see everything" key
is **`vm:manage`**, **not** `vm:view`. The stock `user` role deliberately carries
`vm:view` only so the Instances page works at all — `vm:view` on its own never
reveals other tenants' VMs.
> ⚠️ **The single most common misconfiguration:** granting `vm:manage` to the
> `user` role (or a custom "tenant" role) to "let them manage their VMs". That
> flips on the global view and every user suddenly sees **every** VM in the
> panel. For tenants, grant the granular actions (`vm:update`, `vm:delete`,
> `vm:start`, …) instead of `vm:manage`. Reserve `vm:manage` for
> operators/admins who are *supposed* to see everything.
#### How ownership is enforced everywhere
Ownership is checked not just on the instance list but on every surface a
non-privileged user can reach:
- **Instance list & single-instance actions** — `check_vm_access` / `require_vm_access`
verify `owner_id` before any view/power/console/snapshot/backup/delete op.
- **Live resource endpoints** — the dashboard (`/api/resources/all`) and the
node/server page (`/api/{server_id}/resources`) call `get_owned_vmids` and
filter the live Proxmox listing down to owned VMIDs, so a user with only
`server:view` cannot enumerate other owners' VMs through the node page.
#### Assigning an owner
1. **Automatic** — when a user deploys a VM/LXC they become its `owner_id`.
2. **Manual (admin)** — open the instance → **Owner** button → pick a user →
Save. See [VM / LXC Ownership](#-vm--lxc-ownership) for the REST API.
Instances synced from Proxmox before ownership existed have `owner_id = NULL`
(visible to privileged users only). Bulk-assign them with:
```bash
docker compose exec app python assign_instances_owner.py
```
#### Admin Assignment
#### RBAC vs Workspaces vs Server-assignment vs Ownership
Admins can assign instances to users:
1. Open instance details
2. Click "Assign Owner"
3. Select user from dropdown
4. Save changes
These four mechanisms compose — a non-admin must pass **all** applicable filters:
| Layer | Question it answers | Configured in |
|---|---|---|
| **RBAC role** | *What actions* may this user perform at all? | Users → Roles |
| **Workspaces** | *Which servers* is this user allowed to see? | [Workspaces](#-workspaces) |
| **Server assignment** | *Which specific servers* within those workspaces? | [User → Server Assignment](#-user--server-assignment) |
| **Ownership** | *Which individual VMs/LXC* (unless `vm:manage`/admin)? | [VM / LXC Ownership](#-vm--lxc-ownership) |
### Snapshots
@@ -1309,7 +1690,7 @@ Manage network interfaces (bridges, bonds, VLANs) directly on Proxmox nodes.
#### Accessing the Networks Page
1. Open **Networks** from the sidebar (requires `proxmox.view` permission)
1. Open **Networks** from the sidebar (requires `server:manage` permission; SDN/interface writes need `network:manage`)
2. Select a **Server** from the dropdown
3. The page shows two tabs: **SDN** and **Node Interfaces**
@@ -1537,9 +1918,18 @@ The SHA-256 fingerprint is computed and displayed automatically.
- Regular users see their own keys
- Admins selecting an owner see the owner's keys in addition to their own
### SSH Key Sync on Owner Change
When an admin reassigns a VM or LXC to a different user (via the **Owner** dialog on the instance detail page), the panel automatically:
1. Removes the previous owner's SSH public key from the instance's `ciuser`/authorized keys
2. Injects the new owner's SSH public key
This keeps access credentials in sync with whoever currently owns the instance, without any manual key rotation.
### Admin Key Management
Admins (users with `users.manage` permission) can view and manage SSH keys for any user:
Admins (users with `user:manage` permission) can view and manage SSH keys for any user:
```
GET /api/ssh-keys/user/{user_id} — list keys for a specific user
@@ -1561,10 +1951,12 @@ GET /api/ssh-keys/user/{user_id} — list keys for a specific user
### Supported Languages
- 🇷🇺 Russian
- 🇷🇺 Russian (full coverage as of v1.16.0)
- 🇺🇸 English (default)
- 🇺🇿 Uzbek (partial)
> **v1.16.0** completed a full translation sweep: all previously hardcoded or untranslated strings across the dashboard, instances, images, app store, nodes, roles, storages, scripts, certificates, graphs, wizard, bulk migrate, and access realms are now properly translated in both Russian and English.
### Switching Language
Since v1.5.2, language is a **per-user** preference stored in the database.
@@ -1880,6 +2272,18 @@ Returns `409 workspace_conflict` if any server shares no workspace with the user
Any VM or LXC container can have an **owner** — a regular user responsible for that instance. The owner is displayed in the VM list.
### Isolation
Ownership is enforced everywhere a non-privileged user sees instances, not just the instance list. The live-resource endpoints — `/api/resources/all` (dashboard) and `/api/{server_id}/resources` (node/server page) — filter VMs/LXC by owner (matching each `vmid` against the `vm_instances` cache), so a user with only `proxmox.view` cannot see other owners' instances through the node page. **Admins** and roles with **`vm:manage`** are exempt and see everything.
**Owner is set automatically on create** (`vm_create`/`ct_create`), including when an admin creates an instance on behalf of another user — the deploy flow injects that owner's SSH key (merged with any pasted/library keys) rather than the creator's.
Instances synced from Proxmox before ownership existed, or created before the fix above, have `owner_id = NULL`. A one-time migration backfills these automatically from `deploy_tasks` and `audit_logs` (`vm_create`/`ct_create`/`lxc_create` entries), but only when every applicable piece of evidence names the same existing user — ambiguous cases (e.g. a reused VMID) are deliberately left ownerless. To assign an owner to whatever is left, run the helper script shipped in the backend image:
```bash
docker compose exec app python assign_instances_owner.py
```
### Assigning an owner (Admin)
Open the VM detail page → **Owner** button → select user → Save.
@@ -1908,13 +2312,22 @@ Per-user resource quotas cap how much a user can provision. Four metrics are lim
- **Enforcement** — deploying a VM/LXC that would push any metric over its limit is rejected with **HTTP 429** and a bilingual message (`Превышена квота … / Quota exceeded …`) showing the projected value vs. the limit.
- **Usage source** — current usage is computed from the `vm_instances` cache (`owner_id`), where RAM is stored in MB and disk in GB (no conversion needed).
### Two levels of client-side blocking
The backend's 429 is the only real enforcement, but the UI now warns before a user reaches it, using the same `exhaustedMetrics()` logic (`features/instances/quota.ts`) in both places so the two can't disagree:
- **No headroom at all** (used ≥ limit on any metric) — the **Create Instance** entry point itself is disabled, with a tooltip naming exactly which limits are full. No configuration would fit, so the wizard does not open.
- **Headroom left, but the chosen configuration would exceed it** — the wizard opens normally; from the configuration step onward, the metric that would be exceeded is named and **Next**/**Deploy** are disabled until the request is reduced.
Neither block applies to **admins** — their own quota says nothing about an instance they're creating on behalf of someone else, and the figures shown to them are informational only.
### Managing quotas (Admin)
**Users** page → quota dialog. Live usage bars show current consumption against each limit. Leave a field empty for *unlimited*. Requires the `quota:manage` permission (`quota:view` to read).
### Self-service
Each user sees their own limits and current usage on the **Settings** page. The **Create Instance** wizard shows the remaining quota as a hint on the configuration step.
Each user sees their own limits and current usage on the **Profile** page. The **Create Instance** wizard shows the remaining quota as a hint on the configuration step.
### REST API
@@ -2168,5 +2581,5 @@ A: Not yet, but planned for future versions.
---
*Last updated: June 20, 2026*
*Version: 1.5.2-1*
*Last updated: July 24, 2026*
*Version: 1.13.0*

2498
WIKI.ru.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -47,3 +47,21 @@ FERNET_KEY=
# if running backend standalone without compose.
# DISABLE_UPDATE_CHECK=false
# GITHUB_TOKEN=
# App Store (модуль каталога self-hosted приложений)
# Золотой LXC-шаблон (vztmpl) с предустановленным Docker — из него клонируются
# контейнеры приложений. Обязателен для установки. Подготовка: docs/golden-template.md.
APPSTORE_GOLDEN_TEMPLATE=local:vztmpl/golden-docker_12_amd64.tar.zst
# Архитектура целевых нод (amd64|arm64) — для пометки неподдерживаемых приложений.
APPSTORE_HOST_ARCH=amd64
#
# Источники каталога (через запятую): runtipi, umbrel. По умолчанию только
# runtipi. Umbrel включается явно — тогда при синхронизации подтянутся ~380
# приложений из getumbrel/umbrel-apps + их иконки из галереи.
# APPSTORE_SOURCES=runtipi,umbrel
APPSTORE_SOURCES=runtipi
# Репозитории Umbrel (нужны только если включён источник umbrel):
# UMBREL_APPSTORE_REPO=getumbrel/umbrel-apps
# UMBREL_APPSTORE_REF=master
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — тянутся пофайлово с CDN:
# UMBREL_APPSTORE_GALLERY_CDN=https://getumbrel.github.io/umbrel-apps-gallery

View File

@@ -54,6 +54,7 @@ COPY migrations ./migrations
COPY alembic ./alembic
COPY alembic.ini .
COPY init_admin.py .
COPY assign_instances_owner.py .
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \
CMD curl -fs http://localhost:8000/health || exit 1

View File

@@ -0,0 +1,10 @@
"""App Store API package. Экспортирует агрегированный router для main.py."""
from fastapi import APIRouter
from .catalog import router as catalog_router
from .apps import router as apps_router
router = APIRouter()
router.include_router(catalog_router)
router.include_router(apps_router)

View File

@@ -0,0 +1,261 @@
"""
App Store — эндпоинты жизненного цикла приложений (M2: установка).
POST /api/appstore/apps/install — установить приложение (app:install)
GET /api/appstore/apps — список установленных (app:view)
GET /api/appstore/apps/{id} — детали (app:view)
GET /api/appstore/apps/{id}/operations — журнал операций (app:view)
GET /api/appstore/apps/{id}/credentials — секреты из формы, показать один раз (app:view)
POST /api/appstore/apps/{id}/retry — повтор установки (app:manage)
DELETE /api/appstore/apps/{id} — удалить приложение вместе с LXC (app:manage)
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...db import get_db
from ...models import AppOperation, CatalogApp, InstalledApp, User
from ...services import appstore_engine as engine
router = APIRouter()
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"
ostemplate: Optional[str] = None # переопределение золотого шаблона
# Кастомный рабочий порт приложения (переопределяет порт из каталога).
# None — использовать порт по умолчанию из config.json приложения.
port: Optional[int] = Field(default=None, ge=1, le=65535)
# IPAM (опционально): если задан network — IP выделяется из пула IPAM,
# иначе контейнер получает адрес по DHCP.
ipam_network_id: Optional[int] = None
ipam_pool_id: Optional[int] = None
def _can_admin(user: User) -> bool:
return bool(getattr(user, "is_admin", False)) or user.has_permission("app:manage")
def _get_owned(db: Session, app_id: int, user: User) -> InstalledApp:
ia = db.get(InstalledApp, app_id)
if not ia:
raise HTTPException(status_code=404, detail="Установленное приложение не найдено")
if not _can_admin(user) and ia.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому приложению")
return ia
@router.post("/api/appstore/apps/install")
def install_app(
req: InstallRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:install")),
):
try:
result = engine.install(
db, current_user,
app_id=req.app_id, name=req.name, form_answers=req.form_answers,
server_id=req.server_id, node=req.node, cores=req.cores,
memory=req.memory, disk=req.disk, storage=req.storage,
bridge=req.bridge, ostemplate=req.ostemplate, port=req.port,
ipam_network_id=req.ipam_network_id, ipam_pool_id=req.ipam_pool_id,
)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
# secrets показываются один раз (в т.ч. сгенерированные random-поля)
return {"success": True, **result}
@router.get("/api/appstore/apps")
def list_installed(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
q = db.query(InstalledApp)
if not _can_admin(current_user):
q = q.filter(InstalledApp.owner_id == current_user.id)
rows = q.order_by(InstalledApp.created_at.desc()).all()
return {"items": [a.to_dict() for a in rows]}
@router.get("/api/appstore/apps/{app_id}")
def get_installed(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
ia = _get_owned(db, app_id, current_user)
data = ia.to_dict()
catalog = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
if catalog:
data["catalog"] = catalog.to_dict(light=True)
data["update_available"] = bool(
catalog.tipi_version and ia.tipi_version_installed
and catalog.tipi_version > ia.tipi_version_installed
)
return data
@router.get("/api/appstore/apps/{app_id}/operations")
def app_operations(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
_get_owned(db, app_id, current_user)
ops = (
db.query(AppOperation)
.filter(AppOperation.installed_app_id == app_id)
.order_by(AppOperation.started_at.desc())
.limit(20)
.all()
)
return {"items": [o.to_dict() for o in ops]}
@router.get("/api/appstore/apps/{app_id}/credentials")
def app_credentials(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
"""Секреты из формы (password/random) — расшифрованные. Только владелец/админ.
Дополнительно: учётные данные по умолчанию из манифеста источника
(umbrel-app.yml → default_credentials) и сгенерированный APP_PASSWORD —
для многих приложений это единственный способ узнать логин/пароль админа.
"""
ia = _get_owned(db, app_id, current_user)
env = ia.env_dict()
catalog = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
secret_keys = set()
for f in (catalog.form_fields if catalog else []) or []:
if (f.get("type") or "").lower() in ("password", "random") and f.get("env_variable"):
secret_keys.add(f["env_variable"])
creds = {k: v for k, v in env.items() if k in secret_keys}
dc = (catalog.default_credentials if catalog else None) or {}
if dc.get("username"):
creds.setdefault("USERNAME", dc["username"])
default_pwd = env.get("APP_PASSWORD") if dc.get("deterministic") else dc.get("password")
if default_pwd:
creds.setdefault("PASSWORD", default_pwd)
if env.get("APP_PASSWORD"):
creds.setdefault("APP_PASSWORD", env["APP_PASSWORD"])
return {"credentials": creds}
@router.post("/api/appstore/apps/{app_id}/retry")
def retry_install(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.retry_install(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
class UpdateRequest(BaseModel):
form_answers: dict = {}
@router.post("/api/appstore/apps/{app_id}/update")
def update_app(
app_id: int,
req: UpdateRequest | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.update(db, current_user, app_id, (req.form_answers if req else {}))
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.post("/api/appstore/apps/{app_id}/rollback")
def rollback_app(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.rollback(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.post("/api/appstore/apps/{app_id}/action/{action}")
def lifecycle(
app_id: int,
action: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
"""action: start | stop | restart."""
_get_owned(db, app_id, current_user)
try:
result = engine.lifecycle_action(db, current_user, app_id, action)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, "app": result}
@router.get("/api/appstore/apps/{app_id}/logs")
def app_logs(
app_id: int,
tail: int = 200,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
_get_owned(db, app_id, current_user)
try:
logs = engine.get_logs(db, app_id, tail)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"logs": logs}
@router.post("/api/appstore/apps/reconcile")
def reconcile(
db: Session = Depends(get_db),
_: User = Depends(PermissionChecker("app:view")),
):
return engine.reconcile(db)
@router.delete("/api/appstore/apps/{app_id}")
def delete_app(
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
_get_owned(db, app_id, current_user)
try:
result = engine.delete_app(db, current_user, app_id)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}

View File

@@ -0,0 +1,171 @@
"""
App Store — эндпоинты каталога (M1).
GET /api/appstore/catalog — список карточек (поиск/категория/пагинация)
GET /api/appstore/catalog/meta — метаданные (last_synced_at, count, категории)
GET /api/appstore/catalog/{app_id} — деталь приложения (описание, форма, compose)
GET /api/appstore/catalog/{app_id}/logo — логотип из кэша
POST /api/appstore/catalog/sync — ручная синхронизация (admin)
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...config import settings
from ...db import SessionLocal, get_db
from ...models import CatalogApp, User
from ...services import appstore_engine as engine
from ...services.appstore_catalog import get_catalog_meta, sync_catalog
router = APIRouter()
@router.get("/api/appstore/catalog")
def list_catalog(
q: Optional[str] = None,
category: Optional[str] = None,
source: Optional[str] = None,
available_only: bool = False,
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
query = db.query(CatalogApp)
if available_only:
query = query.filter(CatalogApp.available.is_(True))
if source:
query = query.filter(CatalogApp.source == source.strip().lower())
if q:
like = f"%{q.strip().lower()}%"
query = query.filter(or_(
CatalogApp.name.ilike(like),
CatalogApp.short_desc.ilike(like),
CatalogApp.app_id.ilike(like),
))
rows = query.order_by(CatalogApp.name.asc()).all()
# Фильтр по категории — в Python: categories хранится как JSON-массив строк,
# это надёжнее JSONB-операторов на generic JSON-колонке (независимо от диалекта).
if category:
rows = [r for r in rows if category in (r.categories or [])]
total = len(rows)
page = rows[offset:offset + limit]
return {
"total": total,
"limit": limit,
"offset": offset,
"items": [a.to_dict(light=True) for a in page],
}
@router.get("/api/appstore/catalog/meta")
def catalog_meta(
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
return get_catalog_meta(db)
@router.get("/api/appstore/catalog/{app_id}")
def get_app(
app_id: str,
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="Приложение не найдено в каталоге")
return app.to_dict(with_compose=True)
@router.get("/api/appstore/catalog/{app_id}/logo")
def get_app_logo(
app_id: str,
db: Session = Depends(get_db),
):
# Публичный: логотипы — брендовые картинки, не секреты; тег <img> не шлёт JWT.
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app or not app.logo_path:
raise HTTPException(status_code=404, detail="Логотип не найден")
base = Path(settings.APPSTORE_DATA_DIR).resolve()
path = (base / app.logo_path).resolve()
# защита от path traversal: файл обязан лежать внутри data-каталога
if base not in path.parents or not path.is_file():
raise HTTPException(status_code=404, detail="Файл логотипа отсутствует")
return FileResponse(str(path))
@router.post("/api/appstore/catalog/sync")
async def trigger_sync(
_: object = Depends(PermissionChecker("app:manage")),
):
"""Ручной запуск синхронизации каталога. Блокирующий синк — в пуле потоков."""
def _do() -> dict:
db = SessionLocal()
try:
return sync_catalog(db)
finally:
db.close()
try:
stats = await asyncio.get_event_loop().run_in_executor(None, _do)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Синхронизация не удалась: {e}")
return {"success": True, "stats": stats}
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
class GoldenTemplateRequest(BaseModel):
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"
force: bool = False # пересобрать, даже если шаблон уже есть
@router.post("/api/appstore/golden-template")
def build_golden_template(
req: GoldenTemplateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
"""Собрать золотой LXC-шаблон на ноде (фоновая операция). Прогресс — по WebSocket."""
try:
result = engine.prepare_golden_template(
db, current_user, server_id=req.server_id, node=req.node,
template_storage=req.template_storage, rootfs_storage=req.rootfs_storage,
bridge=req.bridge, force=req.force,
)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.get("/api/appstore/golden-template/status")
def golden_template_status(
server_id: int = Query(...),
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
"""Текущий шаблон конкретного сервера (БД/env) и последняя операция сборки на нём.
vztmpl — файл на storage конкретного Proxmox-сервера, поэтому статус
обязательно скопирован по server_id (шаблон сервера A не существует и не
виден на сервере B).
"""
return engine.golden_template_status(db, server_id)

View File

@@ -11,16 +11,23 @@ from ..models import User, PasswordResetToken
from ..schemas import (
UserCreate, UserResponse, LoginRequest, Token,
ForgotPasswordRequest, ResetPasswordRequest,
TwoFactorStatusResponse, TwoFactorSetupResponse,
TwoFactorEnableRequest, TwoFactorEnableResponse,
TwoFactorDisableRequest, MessageResponse,
)
from ..services import two_factor_service as tfa
from ..auth import (
get_password_hash,
verify_password,
get_password_hash,
verify_password,
create_access_token,
decode_access_token,
get_current_user,
get_current_active_admin,
get_client_ip,
get_current_session_token
get_current_session_token,
security,
)
from fastapi.security import HTTPAuthorizationCredentials
from ..config import settings, utcnow
from ..logging_service import LoggingService
from ..services.security_service import SecurityService
@@ -157,9 +164,46 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
detail="User account is disabled"
)
# Two-factor authentication gate (after password + active checks).
# Password is correct at this point; require the second factor before
# issuing a token or creating a session.
if user.two_factor_enabled:
if not login_data.code:
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)
if not code_ok:
backup = list(user.two_factor_backup_codes or [])
if tfa.consume_backup_code(login_data.code, backup):
user.two_factor_backup_codes = backup
db.commit()
code_ok = True
if not code_ok:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Invalid 2FA code", user_agent
)
LoggingService.log_auth(
db=db,
action="login_failed",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Invalid two-factor code",
user_agent=user_agent,
user_id=user.id,
)
# Use 400 (not 401) so the SPA's global 401 handler does not force a
# redirect/reload and wipe the login form + error message.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid two-factor code",
)
# SUCCESS - Reset failed attempts
SecurityService.reset_failed_attempts(db, user)
# Create session
session, was_other_terminated = SecurityService.create_session(
db, user, client_ip, user_agent
@@ -253,8 +297,138 @@ def logout(
return {"message": "Successfully logged out"}
# ==================== Impersonation ====================
def _user_is_admin(user: User) -> bool:
"""Whether the user has administrator privileges (flag or admin role)."""
return bool(user.is_admin or (user.role and user.role.name == "admin"))
@router.post("/api/auth/impersonate/{user_id}", response_model=Token)
def impersonate_user(
user_id: int,
request: Request,
admin: User = Depends(get_current_active_admin),
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
):
"""Issue a token acting as another user, keeping a link back to the admin.
Only administrators may impersonate. The returned JWT carries an
``impersonator`` claim (the admin's username) so ``/stop-impersonate`` can
restore the original admin session. Nested impersonation is rejected.
"""
# Reject nested impersonation: the acting admin must not already be
# impersonating someone else.
payload = decode_access_token(credentials.credentials)
if payload.get("impersonator"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Already impersonating. Return to your admin account first.",
)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if target.id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot impersonate yourself"
)
if not target.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot impersonate a disabled user"
)
if _user_is_admin(target):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Cannot impersonate another administrator"
)
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent", "")[:500]
session, _ = SecurityService.create_session(db, target, client_ip, user_agent)
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token = create_access_token(
data={"sub": target.username, "impersonator": admin.username},
session_token=session.session_token,
expires_delta=timedelta(minutes=session_timeout),
)
LoggingService.log_auth(
db=db,
action="impersonate_start",
username=admin.username,
ip_address=client_ip,
success=True,
user_agent=user_agent,
user_id=admin.id,
details={"target_user_id": target.id, "target_username": target.username},
)
logger.warning(f"Admin {admin.username} started impersonating {target.username}")
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/api/auth/stop-impersonate", response_model=Token)
def stop_impersonating(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
):
"""Return to the original admin account after impersonating a user."""
payload = decode_access_token(credentials.credentials)
impersonator_username = payload.get("impersonator")
if not impersonator_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Not currently impersonating"
)
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(
status_code=status.HTTP_403_FORBIDDEN,
detail="Original administrator account is no longer available",
)
# Terminate the impersonation session so it does not linger.
impersonation_session = payload.get("session")
if impersonation_session:
SecurityService.terminate_session(db, impersonation_session)
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent", "")[:500]
session, _ = SecurityService.create_session(db, admin, client_ip, user_agent)
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token = create_access_token(
data={"sub": admin.username},
session_token=session.session_token,
expires_delta=timedelta(minutes=session_timeout),
)
LoggingService.log_auth(
db=db,
action="impersonate_stop",
username=admin.username,
ip_address=client_ip,
success=True,
user_agent=user_agent,
user_id=admin.id,
details={"impersonated_username": payload.get("sub")},
)
logger.warning(f"Admin {admin.username} stopped impersonating {payload.get('sub')}")
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/api/auth/me")
def get_current_user_info(current_user: User = Depends(get_current_user)):
def get_current_user_info(
current_user: User = Depends(get_current_user),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""Get current user information with permissions"""
permissions = {}
if current_user.role:
@@ -267,6 +441,14 @@ def get_current_user_info(current_user: User = Depends(get_current_user)):
for perm in perms:
permissions[perm] = True
# Expose the impersonator (original admin username) so the SPA can render a
# "return to admin" banner while acting as another user.
impersonator = None
try:
impersonator = decode_access_token(credentials.credentials).get("impersonator")
except HTTPException:
impersonator = None
return {
"id": current_user.id,
"username": current_user.username,
@@ -274,12 +456,14 @@ def get_current_user_info(current_user: User = Depends(get_current_user)):
"full_name": current_user.full_name,
"is_admin": current_user.is_admin,
"is_active": current_user.is_active,
"impersonator": impersonator,
"role": {
"id": current_user.role.id if current_user.role else None,
"name": current_user.role.name if current_user.role else ("admin" if current_user.is_admin else "user"),
"display_name": current_user.role.display_name if current_user.role else ("Administrator" if current_user.is_admin else "User")
},
"permissions": permissions,
"two_factor_enabled": current_user.two_factor_enabled,
"require_password_change": current_user.require_password_change,
"last_login": current_user.last_login.isoformat() if current_user.last_login else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
@@ -300,6 +484,98 @@ def get_my_sessions(current_user: User = Depends(get_current_user), db: Session
} for s in sessions]
# ==================== Two-Factor Authentication ====================
@router.get("/api/auth/2fa/status", response_model=TwoFactorStatusResponse)
def two_factor_status(current_user: User = Depends(get_current_user)):
"""Return whether 2FA is enabled and how many backup codes remain."""
remaining = len(current_user.two_factor_backup_codes or []) if current_user.two_factor_enabled else 0
return TwoFactorStatusResponse(
enabled=bool(current_user.two_factor_enabled),
backup_codes_remaining=remaining,
)
@router.post("/api/auth/2fa/setup", response_model=TwoFactorSetupResponse)
def two_factor_setup(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Begin 2FA enrollment: generate a fresh secret and QR code.
The secret is stored but 2FA is NOT active until confirmed via /enable.
"""
if current_user.two_factor_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled")
secret = tfa.generate_secret()
current_user.two_factor_secret = secret
db.commit()
uri = tfa.build_otpauth_uri(secret, current_user.username)
return TwoFactorSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=tfa.qr_svg(uri))
@router.post("/api/auth/2fa/enable", response_model=TwoFactorEnableResponse)
def two_factor_enable(
data: TwoFactorEnableRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Confirm enrollment by verifying a TOTP code; return one-time backup codes."""
if current_user.two_factor_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled")
if not current_user.two_factor_secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Run 2FA setup first")
if not tfa.verify_totp(current_user.two_factor_secret, data.code):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
backup_codes = tfa.generate_backup_codes()
current_user.two_factor_backup_codes = tfa.hash_backup_codes(backup_codes)
current_user.two_factor_enabled = True
db.commit()
LoggingService.log_auth(
db=db, action="2fa_enabled", username=current_user.username,
ip_address=get_client_ip(request), success=True, user_id=current_user.id,
)
logger.info(f"User {current_user.username} enabled 2FA")
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
@router.post("/api/auth/2fa/disable", response_model=MessageResponse)
def two_factor_disable(
data: TwoFactorDisableRequest,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Disable 2FA. Requires the account password (and current code if enabled)."""
if not verify_password(data.password, current_user.hashed_password):
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 "")
if not code_ok:
backup = list(current_user.two_factor_backup_codes or [])
code_ok = tfa.consume_backup_code(data.code or "", backup)
if not code_ok:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid two-factor code")
current_user.two_factor_enabled = False
current_user.two_factor_secret = None
current_user.two_factor_backup_codes = None
db.commit()
LoggingService.log_auth(
db=db, action="2fa_disabled", username=current_user.username,
ip_address=get_client_ip(request), success=True, user_id=current_user.id,
)
logger.info(f"User {current_user.username} disabled 2FA")
return MessageResponse(message="Two-factor authentication disabled")
@router.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(
user_data: UserCreate,

View File

@@ -8,6 +8,7 @@ import ipaddress as ipaddress_module
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..db import get_db
@@ -30,29 +31,61 @@ router = APIRouter()
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
def get_networks(
request: Request,
is_active: Optional[bool] = None,
proxmox_server_id: Optional[int] = None,
workspace_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get all IPAM networks"""
"""Get all IPAM networks.
Filtered by the active workspace (X-Active-Workspace header): networks bound to
that workspace plus global networks (workspace_id IS NULL). Admin "All workspaces"
(header 0/absent) shows everything. An explicit ?workspace_id= query param overrides.
"""
from ..models import Workspace
query = db.query(IPAMNetwork)
if is_active is not None:
query = query.filter(IPAMNetwork.is_active == is_active)
if proxmox_server_id:
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
# Resolve workspace scope: explicit query param wins, else the active-workspace header.
ws_filter = workspace_id
if ws_filter is None:
raw = request.headers.get("X-Active-Workspace")
if raw is not None:
try:
parsed = int(raw)
ws_filter = parsed if parsed != 0 else None
except ValueError:
ws_filter = None
if ws_filter:
# Networks of this workspace + global (unassigned) networks.
query = query.filter(
or_(IPAMNetwork.workspace_id == ws_filter, IPAMNetwork.workspace_id.is_(None))
)
networks = query.order_by(IPAMNetwork.name).all()
# Get server names for networks
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
servers = {}
if server_ids:
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
servers = {s.id: s.name for s in server_records}
# Get workspace names for networks
ws_ids = [n.workspace_id for n in networks if n.workspace_id]
workspaces = {}
if ws_ids:
ws_records = db.query(Workspace.id, Workspace.name).filter(Workspace.id.in_(ws_ids)).all()
workspaces = {w.id: w.name for w in ws_records}
# Add computed stats to each network
ipam = IPAMService(db)
result = []
@@ -64,10 +97,11 @@ def get_networks(
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0),
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None,
'workspace_name': workspaces.get(network.workspace_id) if network.workspace_id else None,
}
result.append(network_dict)
return result
@@ -142,9 +176,11 @@ def create_network(
network = IPAMNetwork(**network_data.model_dump())
db.add(network)
db.flush() # assign id before clearing other defaults
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
return network
@@ -164,14 +200,27 @@ def update_network(
update_data = network_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network, field, value)
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} updated network {network.name}")
return network
def _enforce_single_default(db: Session, network: IPAMNetwork) -> None:
"""Keep at most one default network per workspace: if this one is marked
default, clear the flag on siblings in the same workspace."""
if not network.is_default or network.workspace_id is None:
return
db.query(IPAMNetwork).filter(
IPAMNetwork.workspace_id == network.workspace_id,
IPAMNetwork.id != network.id,
IPAMNetwork.is_default == True, # noqa: E712
).update({IPAMNetwork.is_default: False}, synchronize_session=False)
@router.delete("/api/networks/{network_id}")
def delete_network(
network_id: int,

View File

@@ -8,6 +8,7 @@ from .backup import router as backup_router
from .cluster import router as cluster_router
from .networks import router as networks_router
from .lxc import router as lxc_router
from .iso_vm import router as iso_vm_router
from .images import router as images_router
from .firewall import router as firewall_router
from .pools import router as pools_router
@@ -26,6 +27,7 @@ router.include_router(backup_router)
router.include_router(cluster_router)
router.include_router(networks_router)
router.include_router(lxc_router)
router.include_router(iso_vm_router)
router.include_router(images_router)
router.include_router(firewall_router)
router.include_router(pools_router)

View File

@@ -3,7 +3,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from loguru import logger
from typing import List
from typing import List, Optional
import ssl
import asyncio
import httpx
@@ -41,6 +41,58 @@ def can_view_all_instances(current_user: User) -> bool:
return bool(perms.get('vm:manage', False))
def get_visible_server_ids(
request: Request, db: Session, current_user: User
) -> Optional[set]:
"""
Set of Proxmox server IDs the user may see, or ``None`` for "no restriction".
Two filters combine here:
* **Access** — privileged users (admin/moderator) see every server; everyone
else sees servers assigned to them directly OR reachable through a
workspace they belong to. Adding a user to a workspace is therefore
enough — no separate direct assignment is required.
* **Active workspace** — the ``X-Active-Workspace`` header narrows the result
further, for privileged and non-privileged users alike.
``None`` means "everything" (privileged user with no active workspace) and is
deliberately distinct from an empty set, which means "nothing visible".
Callers must treat the two differently — that distinction is exactly what
``/api/cluster/topology`` used to get wrong by skipping this check entirely.
"""
from ...api.workspaces import get_workspace_server_ids
from ...models import WorkspaceUser, WorkspaceServer
ws_filter = get_workspace_server_ids(request, db, current_user)
is_privileged = current_user.is_admin or (
current_user.role and current_user.role.name in ('admin', 'moderator')
)
if is_privileged:
return None if ws_filter is None else set(ws_filter)
assigned_ids = {s.id for s in current_user.assigned_servers}
user_ws_ids = {
r.workspace_id for r in
db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == current_user.id).all()
}
ws_server_ids = set()
if user_ws_ids:
ws_server_ids = {
r.server_id for r in
db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id.in_(user_ws_ids)
).all()
}
effective_ids = assigned_ids | ws_server_ids
if ws_filter is not None:
effective_ids &= set(ws_filter)
return effective_ids
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.
@@ -73,11 +125,121 @@ def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int
)
def get_owned_vmids(db: Session, current_user: User, server_id: Optional[int] = None) -> set:
"""
Set of VMIDs the user owns (optionally scoped to a single Proxmox server).
Used to filter live Proxmox resource listings (which carry no owner
information) down to instances the tenant actually owns. Only meaningful for
non-privileged users — callers should skip filtering when
``can_view_all_instances`` is True.
"""
query = db.query(VMInstance.vmid).filter(
VMInstance.owner_id == current_user.id,
VMInstance.deleted_at.is_(None),
)
if server_id is not None:
query = query.filter(VMInstance.server_id == server_id)
return {row[0] for row in query.all()}
def _get_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth)."""
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth).
Raises HTTPException 503, если у сервера нет валидных кредов: раньше
from_server молча создавал клиент с proxmox=None, и все методы возвращали
пустые списки — пользователь видел «пустой сервер» вместо ошибки настройки.
"""
has_password = bool(getattr(server, "use_password", False) and getattr(server, "password", ""))
has_token = bool(getattr(server, "api_token_name", "") and getattr(server, "api_token_value", ""))
if not (has_password or has_token):
raise HTTPException(
status_code=503,
detail=(
f"Server '{server.name}' has no valid Proxmox credentials configured "
f"(API token or password). Edit the server settings."
),
)
return ProxmoxClient.from_server(server)
def _extract_fingerprint(join_data: dict, target_ip: str = None) -> Optional[str]:
"""
Достать SHA-256 fingerprint из ответа GET /cluster/config/join.
В Proxmox API нет поля `fingerprint` на верхнем уровне — оно лежит в
nodelist[].pve_fp у каждой ноды. Выбираем запись целевой кластерной ноды
по pve_addr/ring0_addr == target_ip, с фолбэком на preferred_node и на
первую ноду с непустым pve_fp.
"""
if not isinstance(join_data, dict):
return None
# Совместимость: если провайдер всё же вернул fingerprint сверху — берём его.
top = join_data.get("fingerprint")
if top:
return top
nodelist = join_data.get("nodelist") or []
def _fp(entry: dict) -> Optional[str]:
return entry.get("pve_fp") if isinstance(entry, dict) else None
# 1) По IP целевой ноды
if target_ip:
for entry in nodelist:
if not isinstance(entry, dict):
continue
if target_ip in (entry.get("pve_addr"), entry.get("ring0_addr")) and _fp(entry):
return entry["pve_fp"]
# 2) По preferred_node
preferred = join_data.get("preferred_node")
if preferred:
for entry in nodelist:
if isinstance(entry, dict) and entry.get("name") == preferred and _fp(entry):
return entry["pve_fp"]
# 3) Первая нода с непустым pve_fp
for entry in nodelist:
if _fp(entry):
return entry["pve_fp"]
return None
def _build_remote_endpoint(target_server: ProxmoxServer, target_node: str) -> str:
"""
Собрать `target-endpoint` строку для PVE `remote_migrate` API.
Формат: apitoken=PVEAPIToken={user}!{token}={secret},host={ip}:{port},fingerprint={fp}
Требует API-токен у целевого сервера (remote_migrate не принимает пароль).
"""
has_token = bool(getattr(target_server, "api_token_name", "") and getattr(target_server, "api_token_value", ""))
if not has_token or getattr(target_server, "use_password", False):
raise ValueError(
f"Server '{target_server.name}' must be configured with an API token "
f"(not password) for remote migration. Edit the target server settings."
)
client = _get_proxmox_client(target_server)
try:
join_data = client.proxmox.cluster.config.join.get()
except Exception as e:
raise ValueError(f"Failed to fetch target cluster fingerprint from '{target_server.name}': {e}") from e
fingerprint = _extract_fingerprint(join_data, target_ip=target_server.ip_address)
if not fingerprint:
raise ValueError(f"Could not determine SSL fingerprint of target server '{target_server.name}'")
token = f"{target_server.api_user}!{target_server.api_token_name}={target_server.api_token_value}"
return (
f"apitoken=PVEAPIToken={token},"
f"host={target_server.ip_address}:{target_server.port},"
f"fingerprint={fingerprint}"
)
def get_next_vmid(db: Session, server_id: int) -> int:
"""Get next available VMID from Proxmox server (sequential)"""
@@ -303,6 +465,15 @@ def save_vm_instance(
existing.template_name = template_name
existing.description = description
existing.extra_config = extra_config
# Владельца проставляем только когда он передан явно: часть вызовов
# (например, сохранение конфига перед удалением) владельца не знает,
# и None не должен обезличивать инстанс.
# Без этой строки владелец, выбранный при создании, терялся: фоновый
# sync_vm_cache идёт каждые 10с и заводит запись с owner_id=NULL ещё
# во время клонирования, поэтому деплой почти всегда попадает сюда,
# а не в ветку создания новой записи.
if owner_id is not None:
existing.owner_id = owner_id
existing.updated_at = func.now()
db.commit()
db.refresh(existing)
@@ -334,7 +505,8 @@ def save_vm_instance(
deleted_existing.template_name = template_name
deleted_existing.description = description
deleted_existing.extra_config = extra_config
deleted_existing.owner_id = owner_id
if owner_id is not None:
deleted_existing.owner_id = owner_id
deleted_existing.status = 'unknown'
deleted_existing.deleted_at = None # Восстанавливаем запись
deleted_existing.updated_at = func.now()

View File

@@ -8,6 +8,7 @@ progress through DeployTask records (with `kind` field) + WebSocket broadcast.
from __future__ import annotations
import re
import shutil
import time
from typing import Optional
@@ -17,7 +18,8 @@ from ...db import SessionLocal
from ...models import ProxmoxServer, VMInstance, OSTemplate, OSTemplateGroup, DeployTask, User
from ...proxmox import ProxmoxClient
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save_vm_instance
from ._helpers import (_get_proxmox_client, get_next_vmid, save_vm_instance,
soft_delete_vm_instance, _build_remote_endpoint)
# Re-use deploy executor and update helper
@@ -603,6 +605,116 @@ def _do_migrate_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type
db.close()
def _do_remote_migrate_sync(task_id: int, server_id: int, vmid: int, node: str, vm_type: str,
target_server_id: int, target_node: str, target_vmid: int,
target_storage: Optional[str], target_bridge: Optional[str],
online: bool, delete_source: bool,
user_id: int, username: str):
"""Мигрировать VM/LXC на другой (независимый) Proxmox-кластер через remote_migrate
API (фоновая задача с прогрессом). В отличие от _do_migrate_sync, источник и цель —
разные ProxmoxServer с разными кредами; UPID задачи живёт на источнике."""
db = SessionLocal()
server = None
is_lxc = (vm_type == 'lxc')
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10,
vmid=vmid, node=node)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
target_server = db.query(ProxmoxServer).filter(ProxmoxServer.id == target_server_id).first()
if not server or not target_server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Proxmox сервер не найден')
return
client = _connect(server)
if not client:
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 10,
error='Не удалось подключиться к исходному Proxmox серверу')
return
_update_deploy_task(task_id, 'running', 'Подготовка remote-endpoint...', 20,
vmid=vmid, node=node)
try:
target_endpoint = _build_remote_endpoint(target_server, target_node)
except ValueError as e:
_update_deploy_task(task_id, 'failed', 'Ошибка конфигурации цели', 20, error=str(e))
return
_update_deploy_task(task_id, 'running', f'Миграция → {target_server.name}/{target_node}...', 30,
vmid=vmid, node=node)
try:
if is_lxc:
upid = client.remote_migrate_container(
node, vmid, target_endpoint, target_vmid,
target_storage=target_storage or None, target_bridge=target_bridge or None,
online=online, delete_source=delete_source,
)
else:
upid = client.remote_migrate_vm(
node, vmid, target_endpoint, target_vmid,
target_storage=target_storage or None, target_bridge=target_bridge or None,
online=online, delete_source=delete_source,
)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30, error=f'Remote migrate failed: {e}')
return
if not upid:
_update_deploy_task(task_id, 'failed', 'Ошибка миграции', 30,
error='Proxmox не вернул UPID задачи миграции')
return
# Прогресс переноса маппится в 30..95 из лога задачи на источнике
_update_deploy_task(task_id, 'running', 'Перенос данных...', 50, vmid=vmid, node=node)
ok = _wait_migrate_with_progress(client, task_id, node, upid, timeout=3600)
if not ok:
_update_deploy_task(task_id, 'failed', 'Миграция не удалась', 50,
error=f'Задача миграции завершилась с ошибкой. {_task_log_tail(client, node, upid)}'[:300])
return
# Переносим локальную запись инстанса (владелец, метаданные) на целевой сервер
try:
cached = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None),
).first()
if cached:
save_vm_instance(
db, target_server_id, target_vmid, target_node, vm_type, cached.name,
cores=cached.cores, memory=cached.memory, disk_size=cached.disk_size,
ip_address=cached.ip_address, ip_prefix=cached.ip_prefix, gateway=cached.gateway,
nameserver=cached.nameserver, template_id=cached.template_id,
template_name=cached.template_name, description=cached.description,
extra_config=cached.extra_config, owner_id=cached.owner_id,
)
if delete_source:
soft_delete_vm_instance(db, server_id, vmid)
except Exception as reg_err:
logger.warning(f"[REMOTE MIGRATE #{task_id}] failed to move instance cache record: {reg_err}")
LoggingService.log_proxmox_action(
db=db, action='remote_migrate',
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
username=username, server_id=server_id,
server_name=server.name, node_name=node,
details={
'target_server_id': target_server_id, 'target_server_name': target_server.name,
'target_node': target_node, 'target_vmid': target_vmid,
'target_storage': target_storage, 'target_bridge': target_bridge,
'online': online, 'delete_source': delete_source,
},
success=True,
)
_update_deploy_task(task_id, 'completed', f'Мигрировано на {target_server.name}/{target_node}', 100,
vmid=target_vmid, node=target_node)
logger.info(f"[REMOTE MIGRATE #{task_id}] {vmid}@{server.name}/{node} -> {target_vmid}@{target_server.name}/{target_node} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[REMOTE MIGRATE #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
def _wait_migrate_with_progress(client, task_id: int, node: str, upid: str,
timeout: int = 3600) -> bool:
"""Дождаться задачи миграции, маппя % копирования диска из лога в 50..95."""
@@ -683,6 +795,18 @@ def _normalize_import_filename(filename: str) -> str:
return base + '.qcow2'
def _normalize_iso_filename(filename: str) -> str:
"""Привести имя файла к допустимому для content=iso расширению.
Proxmox принимает в ISO-хранилище только `*.iso` и `*.img`; ссылки вида
`.../download?file=x` или архивные имена он отвергает ещё на старте задачи.
"""
low = filename.lower()
if low.endswith('.iso') or low.endswith('.img'):
return filename
return filename + '.iso'
def _wait_download_with_progress(client, task_id: int, node: str, upid: str,
lo: int = 15, hi: int = 100,
timeout: int = 3600) -> bool:
@@ -732,12 +856,13 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
url: Optional[str], template: Optional[str],
checksum: Optional[str], checksum_algorithm: Optional[str],
user_id: int, username: str):
"""Скачать образ (qcow2 или vztmpl) в хранилище ноды с прогрессом.
"""Скачать образ (qcow2, vztmpl или iso) в хранилище ноды с прогрессом.
kind:
'qcow2' — cloud-образ ВМ, content=import (url обязателен)
'vztmpl' — LXC-шаблон: либо template (репозиторий Proxmox, aplinfo),
либо url (произвольное зеркало, content=vztmpl)
'iso' — установочный ISO, content=iso (url обязателен)
"""
db = SessionLocal()
server = None
@@ -753,15 +878,26 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
error='Не удалось подключиться к Proxmox серверу')
return
content = 'vztmpl' if kind == 'vztmpl' else 'import'
dl_filename = _normalize_import_filename(filename) if content == 'import' else filename
content = {'vztmpl': 'vztmpl', 'iso': 'iso'}.get(kind, 'import')
if content == 'import':
dl_filename = _normalize_import_filename(filename)
elif content == 'iso':
dl_filename = _normalize_iso_filename(filename)
else:
dl_filename = filename
# Идемпотентность: если файл уже есть в хранилище — не качаем повторно
# Идемпотентность: если файл уже есть в хранилище — не качаем повторно,
# но при наличии контрольной суммы удаляем кэш и перекачиваем, чтобы
# Proxmox проверил актуальность образа.
existing_volid = f'{storage}:{content}/{dl_filename}'
if client.volume_exists(node, storage, existing_volid):
_update_deploy_task(task_id, 'completed', f'Образ {dl_filename} уже в хранилище', 100, node=node)
logger.info(f"[IMG DL #{task_id}] {dl_filename} already present on {node}:{storage}")
return
if checksum:
logger.info(f"[IMG DL #{task_id}] Removing cached {existing_volid} to re-verify checksum")
client.delete_volume(node, storage, existing_volid)
else:
_update_deploy_task(task_id, 'completed', f'Образ {dl_filename} уже в хранилище', 100, node=node)
logger.info(f"[IMG DL #{task_id}] {dl_filename} already present on {node}:{storage}")
return
_update_deploy_task(task_id, 'running', f'Старт загрузки {dl_filename}...', 15, node=node)
try:
@@ -806,6 +942,69 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
db.close()
# ==================== Image upload (local file) ====================
def _do_image_upload_sync(task_id: int, server_id: int, node: str, storage: str,
content: str, filename: str, tmp_path: str, tmp_dir: str,
checksum: Optional[str], checksum_algorithm: Optional[str],
user_id: int, username: str):
"""Проксировать локально загруженный файл (уже сохранён на диске бэкенда
endpoint'ом /images/upload) в хранилище ноды через Proxmox upload API.
В отличие от _do_image_download_sync, здесь нет UPID для polling'а прогресса
(Proxmox отдаёт upload как единый блокирующий multipart POST) — прогресс
задачи поэтому грубее: 'running' на время самого запроса, 'completed' по
его завершении.
"""
db = SessionLocal()
server = None
try:
_update_deploy_task(task_id, 'running', 'Подключение к Proxmox...', 10, node=node)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
_update_deploy_task(task_id, 'failed', 'Сервер не найден', 0, error='Proxmox сервер не найден')
return
client = _connect(server)
if not client:
_update_deploy_task(task_id, 'failed', 'Ошибка подключения к Proxmox', 10,
error='Не удалось подключиться к Proxmox серверу')
return
# Идемпотентность: если файл уже есть в хранилище — не грузим повторно
existing_volid = f'{storage}:{content}/{filename}'
if client.volume_exists(node, storage, existing_volid):
_update_deploy_task(task_id, 'completed', f'Файл {filename} уже в хранилище', 100, node=node)
logger.info(f"[IMG UPLOAD #{task_id}] {filename} already present on {node}:{storage}")
return
_update_deploy_task(task_id, 'running', f'Загрузка {filename} на ноду...', 30, node=node)
try:
with open(tmp_path, 'rb') as f:
client.upload_file(node, storage, content, filename, f,
checksum=checksum, checksum_algorithm=checksum_algorithm)
except Exception as e:
_update_deploy_task(task_id, 'failed', 'Ошибка загрузки', 30, error=f'Upload failed: {e}')
return
LoggingService.log_proxmox_action(
db=db, action='upload_image',
resource_type='storage', resource_id=0,
username=username, resource_name=filename,
server_id=server_id, server_name=server.name, node_name=node,
details={'content': content, 'storage': storage}, success=True,
)
_update_deploy_task(task_id, 'completed', f'Файл {filename} загружен', 100, node=node)
logger.info(f"[IMG UPLOAD #{task_id}] {filename} -> {node}:{storage} by {username}")
except Exception as e:
err = str(e)
logger.error(f"[IMG UPLOAD #{task_id}] failed: {err}")
_update_deploy_task(task_id, 'failed', f'Ошибка: {err[:150]}', 0, error=err)
finally:
db.close()
shutil.rmtree(tmp_dir, ignore_errors=True)
def _get_or_create_os_group(db, hint: Optional[str]) -> int:
"""Найти/создать группу шаблонов по ОС (Ubuntu, AlmaLinux, ...).
@@ -864,10 +1063,19 @@ def _do_image_template_sync(task_id: int, server_id: int, node: str,
filename = _normalize_import_filename(filename)
import_volid = f'{import_storage}:import/{filename}'
need_download = True
if client.volume_exists(node, import_storage, import_volid):
# Образ уже скачан прошлой попыткой — пропускаем загрузку (идемпотентность)
_update_deploy_task(task_id, 'running', 'Образ уже загружен, пропуск...', 40, node=node)
else:
if checksum:
# Есть кэшированный файл, но задана контрольная сумма — удаляем
# и перекачиваем: Proxmox проверит сумму при новой загрузке.
logger.info(f"[IMG TPL #{task_id}] Removing cached {import_volid} to re-verify checksum")
client.delete_volume(node, import_storage, import_volid)
else:
# Без контрольной суммы доверяем кэшу (чистая идемпотентность)
need_download = False
_update_deploy_task(task_id, 'running', 'Образ уже загружен, пропуск...', 40, node=node)
if need_download:
_update_deploy_task(task_id, 'running', f'Загрузка образа {filename}...', 10, node=node)
try:
dl_upid = client.download_url(node, import_storage, url, 'import', filename,

View File

@@ -39,6 +39,14 @@ def get_storages(
try:
client = _get_proxmox_client(server)
storages = client.get_cluster_storages()
# Конфиг кластера общий для всех нод и ничего не говорит о том, поднялось ли
# хранилище на конкретной ноде. Доступность берём из /cluster/resources —
# один запрос на весь кластер.
node_status = client.get_storage_node_status()
for s in storages:
statuses = node_status.get(s.get("storage"), [])
s["nodes_status"] = statuses
s["inactive_nodes"] = [n["node"] for n in statuses if not n["active"]]
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error listing storages for server {server_id}: {e}")

View File

@@ -5,7 +5,7 @@ Endpoints for creating Proxmox clusters, joining nodes, ejecting nodes,
and preparing nodes for join (backup + cleanup).
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from loguru import logger
@@ -16,7 +16,7 @@ from ...db import get_db
from ...models import ProxmoxServer, User
from ...auth import PermissionChecker
from ...proxmox import ProxmoxClient
from ._helpers import _get_proxmox_client
from ._helpers import _get_proxmox_client, _extract_fingerprint, get_visible_server_ids
from ...proxmox import _run_in_executor
router = APIRouter()
@@ -58,14 +58,25 @@ def _get_password_client(server: ProxmoxServer, override_password: str = None) -
@router.get("/api/cluster/topology")
def get_cluster_topology(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
):
"""
Получить все серверы, сгруппированные по кластеру.
Получить серверы, видимые пользователю, сгруппированные по кластеру.
Standalone-серверы (cluster_name=NULL) — в отдельном списке.
Фильтруется так же, как GET /api/servers: по доступу пользователя и по
активной рабочей области (заголовок X-Active-Workspace).
"""
servers = db.query(ProxmoxServer).all()
visible_ids = get_visible_server_ids(request, db, current_user)
if visible_ids is not None and not visible_ids:
return {"clusters": {}, "standalone": []}
query = db.query(ProxmoxServer)
if visible_ids is not None:
query = query.filter(ProxmoxServer.id.in_(visible_ids))
servers = query.all()
clusters: dict = {}
standalone = []
@@ -78,6 +89,7 @@ def get_cluster_topology(
"ip_address": s.ip_address,
"port": s.port,
"is_online": s.is_online,
"last_error_kind": s.last_error_kind,
"cluster_name": s.cluster_name,
}
if s.cluster_name:
@@ -200,7 +212,7 @@ def get_cluster_join_info(
"server_id": server_id,
"server_name": server.name,
"cluster_name": totem.get("cluster_name") or server.cluster_name,
"fingerprint": data.get("fingerprint"),
"fingerprint": _extract_fingerprint(data, target_ip=server.ip_address),
"nodelist": data.get("nodelist", []),
"config_digest": data.get("config_digest"),
"preferred_node_ip": server.ip_address,
@@ -456,13 +468,18 @@ async def join_cluster(
cluster_server_id (int): ID любой ноды существующего кластера
rootpw (str): root-пароль присоединяемой ноды
link0 (str, optional): Локальный IP присоединяемой ноды для corosync
cluster_host (str, optional): Адрес кластерной ноды, к которому
подключается присоединяемая нода (IP в corosync-сети). Если не
задан — берётся ip_address кластерной ноды из панели, что может
быть публичным FQDN и недоступно из выделенной приватной сети.
Returns 409 если нода содержит VM/LXC (сначала вызовите prepare-join).
"""
node_server_id = body.get("node_server_id")
cluster_server_id = body.get("cluster_server_id")
rootpw = body.get("rootpw", "").strip()
link0 = body.get("link0")
link0 = (body.get("link0") or "").strip() or None
cluster_host = (body.get("cluster_host") or "").strip() or None
if not node_server_id or not cluster_server_id:
raise HTTPException(
@@ -525,7 +542,11 @@ async def join_cluster(
)
join_data = join_info_result.get("data", {})
fingerprint = join_data.get("fingerprint")
# Адрес, к которому реально подключается присоединяемая нода: явный
# cluster_host (IP corosync-сети) в приоритете, иначе — ip_address из панели.
connect_host = cluster_host or cluster_server.ip_address
fingerprint = _extract_fingerprint(join_data, target_ip=connect_host) \
or _extract_fingerprint(join_data, target_ip=cluster_server.ip_address)
cluster_name = join_data.get("totem", {}).get("cluster_name") or cluster_server.cluster_name
if not fingerprint:
@@ -538,7 +559,7 @@ async def join_cluster(
joining_client = _get_password_client(node_server, rootpw)
result = await _run_in_executor(
joining_client.join_cluster,
cluster_host=cluster_server.ip_address,
cluster_host=connect_host,
rootpw=rootpw,
fingerprint=fingerprint,
link0=link0,
@@ -550,25 +571,249 @@ async def join_cluster(
detail=f"Ошибка присоединения к кластеру: {result.get('error')}"
)
# --- Обновить панель: выставить cluster_name ---
node_server.cluster_name = cluster_name
db.commit()
node_upid = result.get("upid")
logger.info(
f"User {current_user.username} joined '{node_server.name}' "
f"to cluster '{cluster_name}' (via '{cluster_server.name}')"
)
# --- Подтвердить членство перед записью в БД ---
# Задача join выполняется асинхронно и может упасть (напр. таймаут
# подключения к кластерной ноде по недоступному адресу). При УСПЕШНОМ join
# API присоединяемой ноды перезапускается, поэтому надёжнее спрашивать
# кластерную ноду (node1): как только присоединяемая нода реально войдёт в
# кластер, она появится в её списке нод. Окно ~40с — чтобы уложиться в
# proxy_read_timeout (60с) фронтового nginx.
joined_confirmed = False
for _ in range(20):
await asyncio.sleep(2)
try:
cluster_nodes = await _run_in_executor(cluster_client.get_nodes)
except Exception:
cluster_nodes = []
names = {n.get("node") for n in cluster_nodes if isinstance(n, dict)}
if node_name in names:
joined_confirmed = True
break
if joined_confirmed:
# Только теперь помечаем ноду как члена кластера в панели.
node_server.cluster_name = cluster_name
db.commit()
logger.info(
f"User {current_user.username} joined '{node_server.name}' "
f"to cluster '{cluster_name}' (via '{cluster_server.name}')"
)
message = f"Нода '{node_server.name}' присоединена к кластеру '{cluster_name}'."
else:
# Членство не подтверждено — НЕ выставляем cluster_name, чтобы панель
# не показывала ложное присоединение.
logger.warning(
f"Join of '{node_server.name}' to '{cluster_name}' not confirmed "
f"within timeout (upid={node_upid})"
)
message = (
f"Задача присоединения запущена (upid={node_upid}), но членство "
f"'{node_server.name}' в кластере пока не подтверждено. Проверьте задачу; "
"если нода не вошла в кластер — убедитесь, что кластерная нода доступна "
"с присоединяемой по адресу corosync (link0), а не по публичному FQDN."
)
return {
"success": True,
"node_server_id": node_server_id,
"node_name": node_server.name,
"cluster_name": cluster_name if joined_confirmed else None,
"confirmed": joined_confirmed,
"upid": node_upid,
"message": message,
}
# ==================== GET /api/cluster/{server_id}/import-info ====================
@router.get("/api/cluster/{server_id}/import-info")
async def get_cluster_import_info(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("cluster:manage")),
):
"""
Обнаружить кластер, сформированный на стороне Proxmox в обход панели
(напр. через pvecm/веб-интерфейс Proxmox), и сопоставить его ноды с
серверами, уже добавленными в панель — по IP/hostname из nodelist.
Ничего не меняет, только читает.
"""
server = _get_server_or_404(db, server_id)
if server.cluster_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сервер '{server.name}' уже привязан к кластеру '{server.cluster_name}' в панели"
)
client = _get_proxmox_client(server)
is_cluster = await _run_in_executor(client.is_cluster)
if not is_cluster:
return {
"server_id": server_id,
"is_cluster": False,
"cluster_name": None,
"nodes": [],
"message": "Нода не состоит в кластере Proxmox — нечего импортировать.",
}
join_info_result = await _run_in_executor(client.get_cluster_join_info)
if not join_info_result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Не удалось получить информацию о кластере: {join_info_result.get('error')}"
)
data = join_info_result.get("data", {})
cluster_name = data.get("totem", {}).get("cluster_name")
nodelist = data.get("nodelist", [])
all_servers = db.query(ProxmoxServer).all()
def find_match(entry: dict):
addr_candidates = {entry.get("pve_addr"), entry.get("ring0_addr")}
addr_candidates.discard(None)
for s in all_servers:
if s.ip_address in addr_candidates:
return s
name = entry.get("name")
if name:
for s in all_servers:
if s.hostname == name:
return s
return None
nodes = []
for entry in nodelist:
if not isinstance(entry, dict):
continue
match = find_match(entry)
nodes.append({
"name": entry.get("name"),
"address": entry.get("pve_addr") or entry.get("ring0_addr"),
"matched_server_id": match.id if match else None,
"matched_server_name": match.name if match else None,
"already_linked": bool(match and match.cluster_name == cluster_name),
"linked_elsewhere": bool(match and match.cluster_name and match.cluster_name != cluster_name),
})
return {
"server_id": server_id,
"is_cluster": True,
"cluster_name": cluster_name,
"upid": result.get("upid"),
"nodes": nodes,
}
# ==================== POST /api/cluster/import ====================
@router.post("/api/cluster/import")
async def import_cluster(
body: dict,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("cluster:manage")),
):
"""
Привязать существующие панельные записи серверов к реальному
Proxmox-кластеру, обнаруженному на server_id (см. import-info).
Только правит cluster_name в БД панели — никаких pvecm-операций не
выполняется, реальный кластер не затрагивается.
Body:
server_id (int): нода-якорь, на которой обнаружен кластер
server_ids (list[int]): панельные ID серверов для привязки к кластеру
(каждый должен совпадать по IP или hostname с нодой из nodelist
кластера — иначе привязка для него отклоняется)
"""
server_id = body.get("server_id")
server_ids = body.get("server_ids") or []
if not server_id or not server_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Необходимо указать server_id и server_ids"
)
anchor = _get_server_or_404(db, server_id)
client = _get_proxmox_client(anchor)
if not await _run_in_executor(client.is_cluster):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Сервер '{anchor.name}' не состоит в кластере Proxmox"
)
join_info_result = await _run_in_executor(client.get_cluster_join_info)
if not join_info_result.get("success"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Не удалось получить информацию о кластере: {join_info_result.get('error')}"
)
data = join_info_result.get("data", {})
cluster_name = data.get("totem", {}).get("cluster_name")
if not cluster_name:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Не удалось определить имя кластера"
)
nodelist = data.get("nodelist", [])
addr_by_node: set = set()
name_by_node: set = set()
for entry in nodelist:
if not isinstance(entry, dict):
continue
for addr in (entry.get("pve_addr"), entry.get("ring0_addr")):
if addr:
addr_by_node.add(addr)
if entry.get("name"):
name_by_node.add(entry.get("name"))
applied = []
errors = []
for sid in server_ids:
s = db.query(ProxmoxServer).filter(ProxmoxServer.id == sid).first()
if not s:
errors.append({"server_id": sid, "error": "Сервер не найден"})
continue
if s.cluster_name == cluster_name:
applied.append(sid) # уже привязан — идемпотентно
continue
if s.cluster_name and s.cluster_name != cluster_name:
errors.append({
"server_id": sid,
"error": f"'{s.name}' уже привязан к другому кластеру '{s.cluster_name}'",
})
continue
if s.ip_address not in addr_by_node and s.hostname not in name_by_node:
errors.append({
"server_id": sid,
"error": f"IP/hostname '{s.name}' не найден среди нод кластера '{cluster_name}' — привязка отклонена",
})
continue
s.cluster_name = cluster_name
applied.append(sid)
db.commit()
logger.info(
f"User {current_user.username} imported existing cluster '{cluster_name}' "
f"for panel servers {applied} (anchor='{anchor.name}', errors={len(errors)})"
)
return {
"success": len(errors) == 0,
"cluster_name": cluster_name,
"applied": applied,
"errors": errors,
"message": (
f"Нода '{node_server.name}' присоединяется к кластеру '{cluster_name}'. "
"После завершения задачи интерфейс ноды перезапустится. "
"Отслеживайте прогресс через upid."
f"Кластер '{cluster_name}' импортирован для {len(applied)} серверов."
if not errors
else f"Импортировано {len(applied)}, ошибок: {len(errors)}."
),
}
@@ -591,10 +836,14 @@ def eject_cluster_node(
node_name (str): hostname ноды для удаления
rootpw (str, optional): root-пароль для SSH fallback
clear_panel_entry (bool, optional): Очистить cluster_name у панельной записи ноды (default: true)
panel_only (bool, optional): Не трогать Proxmox, только отвязать запись в
панели. Для случаев, когда нода на самом деле не в кластере (напр.
прошлый join не завершился) или недоступна для delnode/SSH.
"""
node_name = body.get("node_name", "").strip()
rootpw = body.get("rootpw")
clear_panel_entry = body.get("clear_panel_entry", True)
panel_only = bool(body.get("panel_only", False))
if not node_name:
raise HTTPException(
@@ -605,6 +854,35 @@ def eject_cluster_node(
# server_id — это нода, с которой выполняем команду (остающаяся в кластере)
server = _get_server_or_404(db, server_id)
# --- Режим "только панель": не обращаемся к Proxmox, чистим запись в БД ---
if panel_only:
ejected_server = db.query(ProxmoxServer).filter(
ProxmoxServer.hostname == node_name,
ProxmoxServer.cluster_name == server.cluster_name,
).first()
if not ejected_server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Панельная запись ноды '{node_name}' в кластере "
f"'{server.cluster_name}' не найдена."
)
ejected_server.cluster_name = None
db.commit()
logger.info(
f"User {current_user.username} detached panel entry '{ejected_server.name}' "
f"from cluster '{server.cluster_name}' (panel-only, no Proxmox op)"
)
return {
"success": True,
"ejected_node": node_name,
"cluster_name": server.cluster_name,
"method": "panel",
"message": (
f"Запись ноды '{node_name}' отвязана от кластера в панели. "
"Proxmox не затрагивался."
),
}
# Build client — prefer password for SSH fallback availability
if rootpw or server.password:
client = _get_password_client(server, rootpw)

View File

@@ -7,9 +7,16 @@ qcow2 (content=import) / vztmpl с прогрессом через DeployTask +
"""
import asyncio
import os
import re
import shutil
import tempfile
from email.message import Message as _EmailMessage
from typing import Optional
from urllib.parse import unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, model_validator
from sqlalchemy.orm import Session
@@ -24,6 +31,18 @@ from ._helpers import _get_proxmox_client
router = APIRouter()
def _sanitize_filename(name: str, default: str = 'upload.iso') -> str:
"""Свести произвольное (клиентское) имя файла к безопасному basename.
Отбрасывает путь и заменяет всё, кроме букв/цифр/._- на подчёркивание;
strip('.') нейтрализует '..', '.' и ведущие точки — иначе basename вида
'..' привёл бы к записи временного файла за пределами tmp_dir.
"""
name = os.path.basename((name or '').strip().replace('\\', '/'))
name = re.sub(r'[^A-Za-z0-9._-]+', '_', name).strip('.')
return name or default
# ── Schemas ──────────────────────────────────────────────────────────────────
class ImageDownloadResponse(BaseModel):
@@ -38,7 +57,7 @@ class ImageDownloadRequest(BaseModel):
# Либо ссылка на запись каталога/зеркала...
source_id: Optional[str] = None
# ...либо явные параметры произвольного образа:
kind: Optional[str] = None # 'qcow2' | 'vztmpl'
kind: Optional[str] = None # 'qcow2' | 'vztmpl' | 'iso'
url: Optional[str] = None
template: Optional[str] = None # имя шаблона из репозитория (aplinfo)
filename: Optional[str] = None
@@ -82,7 +101,7 @@ def get_image_catalog(
class ImageMirrorBase(BaseModel):
name: str
kind: str = 'qcow2' # 'qcow2' | 'vztmpl'
kind: str = 'qcow2' # 'qcow2' | 'vztmpl' | 'iso'
os: Optional[str] = None
version: Optional[str] = None
arch: str = 'amd64'
@@ -95,12 +114,12 @@ class ImageMirrorBase(BaseModel):
@model_validator(mode='after')
def _check(self):
if self.kind not in ('qcow2', 'vztmpl'):
raise ValueError("kind должен быть 'qcow2' или 'vztmpl'")
if self.kind not in ('qcow2', 'vztmpl', 'iso'):
raise ValueError("kind должен быть 'qcow2', 'vztmpl' или 'iso'")
if not self.url and not self.template:
raise ValueError('Нужен url или template')
if self.kind == 'qcow2' and not self.url:
raise ValueError('Для qcow2 нужен url')
if self.kind in ('qcow2', 'iso') and not self.url:
raise ValueError(f'Для {self.kind} нужен url')
return self
@@ -243,6 +262,47 @@ def get_image_target_storages(
raise HTTPException(status_code=500, detail=str(e))
def _filename_from_url_response(url: str, headers) -> str:
"""Имя файла из Content-Disposition ответа, иначе — хвост пути (после редиректов)."""
cd = headers.get('content-disposition')
if cd:
msg = _EmailMessage()
msg['content-disposition'] = cd
name = msg.get_filename()
if name:
return unquote(name.strip())
path = urlparse(url).path
return unquote(path.rsplit('/', 1)[-1]) or 'download'
@router.get("/api/images/resolve-filename")
async def resolve_image_filename(
url: str = Query(..., description="URL образа для определения реального имени файла"),
current_user: User = Depends(PermissionChecker("template:view")),
):
"""
Определить настоящее имя файла по произвольному URL.
Простой хвост пути (как делает фронт на лету) не годится для ссылок вида
/download?id=123 без расширения — здесь сервер обычно возвращает реальное
имя в Content-Disposition, поэтому сначала пробуем HEAD и читаем заголовок,
а если сервер не поддерживает HEAD — открываем GET-поток и сразу же его
закрываем, не скачивая тело.
"""
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0, verify=False) as hc:
try:
resp = await hc.head(url)
if resp.status_code >= 400:
raise httpx.HTTPStatusError("HEAD not usable", request=resp.request, response=resp)
return {"filename": _filename_from_url_response(str(resp.url), resp.headers)}
except Exception:
async with hc.stream('GET', url) as stream_resp:
return {"filename": _filename_from_url_response(str(stream_resp.url), stream_resp.headers)}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Не удалось определить имя файла: {e}")
# ── Download ─────────────────────────────────────────────────────────────────
def _resolve_source(db: Session, req: ImageDownloadRequest) -> dict:
@@ -305,10 +365,12 @@ async def download_image(
raise HTTPException(status_code=404, detail="Proxmox server not found")
src = _resolve_source(db, req)
if src['kind'] not in ('qcow2', 'vztmpl', 'iso'):
raise HTTPException(status_code=400, detail=f"Неизвестный тип образа: {src['kind']}")
if src['kind'] == 'vztmpl' and not src['template'] and not src['url']:
raise HTTPException(status_code=400, detail="Для vztmpl нужен url или template")
if src['kind'] != 'vztmpl' and not src['url']:
raise HTTPException(status_code=400, detail="Для qcow2 нужен url")
raise HTTPException(status_code=400, detail=f"Для {src['kind']} нужен url")
to_template = req.to_template and src['kind'] == 'qcow2'
kind = 'image_template' if to_template else 'image_download'
@@ -346,3 +408,80 @@ async def download_image(
logger.info(f"[IMG DL] Task #{task_id} ({kind}) queued: {src['filename']} by {current_user.username}")
return ImageDownloadResponse(task_id=task_id, status='pending', name=src['name'])
@router.post("/api/{server_id}/images/upload", response_model=ImageDownloadResponse)
async def upload_image_file(
server_id: int,
node: str = Form(...),
storage: str = Form(...),
content: str = Form('iso'),
filename: Optional[str] = Form(None),
checksum: Optional[str] = Form(None),
checksum_algorithm: Optional[str] = Form(None),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("template:manage")),
):
"""Загрузить локальный файл (ISO/vztmpl) на ноду.
Сохраняем присланный файл во временный файл на диске бэкенда (basename ==
итоговое имя в хранилище — так его подхватит client.upload_file через
file_obj.name), затем фоновый воркер проксирует его в Proxmox через upload
API и чистит за собой временную директорию.
"""
from ..templates import _deploy_executor
from .async_ops import _do_image_upload_sync, _normalize_iso_filename
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
if content not in ('iso', 'vztmpl'):
raise HTTPException(status_code=400, detail=f"Неподдерживаемый тип контента: {content}")
final_name = _sanitize_filename(filename or file.filename or 'upload.iso')
if content == 'iso':
final_name = _normalize_iso_filename(final_name)
tmp_dir = tempfile.mkdtemp(prefix='pveup_')
tmp_path = os.path.join(tmp_dir, final_name)
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)
except Exception as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise HTTPException(status_code=400, detail=f"Не удалось сохранить файл: {e}")
finally:
await file.close()
if size == 0:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise HTTPException(status_code=400, detail="Пустой файл")
task = DeployTask(
status='pending', step='В очереди...', progress=0,
kind='image_upload', name=final_name,
server_id=server_id, user_id=current_user.id, node=node,
)
db.add(task)
db.commit()
db.refresh(task)
task_id = task.id
loop = asyncio.get_event_loop()
loop.run_in_executor(
_deploy_executor, _do_image_upload_sync,
task_id, server_id, node, storage, content,
final_name, tmp_path, tmp_dir, checksum, checksum_algorithm,
current_user.id, current_user.username,
)
logger.info(f"[IMG UPLOAD] Task #{task_id} queued: {final_name} ({size} bytes) by {current_user.username}")
return ImageDownloadResponse(task_id=task_id, status='pending', name=final_name)

View File

@@ -0,0 +1,373 @@
"""
API endpoints for creating blank KVM VMs prepared for an OS install from an ISO.
Unlike the template deploy (`/templates/api/deploy`) and the cloud-image import
(`images.py`), nothing is cloned here: the VM gets an empty disk, the ISO is
mounted as a CD-ROM and put first in the boot order, so the guest boots straight
into the installer via the noVNC console.
"""
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...db import get_db, SessionLocal
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
router = APIRouter()
_iso_vm_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="iso_vm")
DISK_BUSES = ("scsi", "sata", "virtio")
NET_MODELS = ("virtio", "e1000", "rtl8139", "vmxnet3")
BIOS_TYPES = ("seabios", "ovmf")
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── Pydantic schemas ─────────────────────────────────────────────────────────
class ISOVMCreateRequest(BaseModel):
server_id: int
node: str
name: str
iso: str # volid, e.g. "local:iso/ubuntu-24.04.iso"
vmid: Optional[int] = None
cores: int = 2
memory: int = 2048 # МБ
disk: int = 32 # ГБ
storage: str = "local-lvm"
bridge: str = "vmbr0"
ostype: str = "l26"
disk_bus: str = "scsi"
net_model: str = "virtio"
bios: str = "seabios"
tpm: bool = False
extra_iso: Optional[str] = None # второй CD-ROM, обычно virtio-win с драйверами
ip_address: Optional[str] = None
gateway: Optional[str] = None
ipam_network_id: Optional[int] = None
ipam_pool_id: Optional[int] = None
owner_id: Optional[int] = None
start_after_create: bool = True
onboot: bool = False
class ISOVMCreateResponse(BaseModel):
task_id: int
status: str
name: str
# ── Helpers ──────────────────────────────────────────────────────────────────
def _update_task(task_id: int, status: str, step: str, progress: int,
vmid: int = None, node: str = None, error: str = None):
db = SessionLocal()
try:
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
if not task:
return
task.status = status
task.step = step
task.progress = progress
if vmid is not None:
task.vmid = vmid
if node is not None:
task.node = node
if error is not None:
task.error_message = error
if status == "running" and not task.started_at:
task.started_at = _utcnow()
if status in ("completed", "failed"):
task.completed_at = _utcnow()
db.commit()
try:
broadcast_task_update(task.user_id, "deploy_task_update", task.to_dict())
except Exception:
pass
finally:
db.close()
def _do_iso_vm_create(task_id: int, req: ISOVMCreateRequest, user_id: int,
username: str, owner_id: Optional[int] = None):
"""Blocking VM creation — runs in a thread pool."""
db = SessionLocal()
server = None
new_vmid = None
ipam_allocation = None
try:
_update_task(task_id, "running", "Подготовка...", 5)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
if not server:
_update_task(task_id, "failed", "Сервер не найден", 0, error="Сервер Proxmox не найден")
return
_update_task(task_id, "running", "Подключение к Proxmox...", 10)
client = _get_proxmox_client(server)
if not client.is_connected():
_update_task(task_id, "failed", "Ошибка подключения к Proxmox", 10,
error="Не удалось подключиться к Proxmox серверу")
return
_update_task(task_id, "running", "Генерация VMID...", 20)
new_vmid = req.vmid or get_next_vmid(db, server.id)
# IPAM: адрес только резервируется — без cloud-init его придётся
# прописать вручную в установщике, поэтому он попадает в шаг задачи.
allocated_ip = req.ip_address
gateway = req.gateway
network_mask = "24"
if req.ipam_network_id and not req.ip_address:
_update_task(task_id, "running", "Резервирование IP-адреса (IPAM)...", 25,
vmid=new_vmid, node=req.node)
ipam = IPAMService(db)
ipam_allocation, error = ipam.auto_allocate_ip(
network_id=req.ipam_network_id,
pool_id=req.ipam_pool_id,
resource_type="vm",
resource_name=req.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=req.node,
allocated_by=username,
notes=f"Reserved for ISO install of VM {req.name}",
)
if not error:
allocated_ip = ipam_allocation.ip_address
elif req.ip_address and req.ipam_network_id:
ipam = IPAMService(db)
ipam_allocation, _ = ipam.allocate_ip(
ip_address=req.ip_address,
network_id=req.ipam_network_id,
pool_id=req.ipam_pool_id,
resource_type="vm",
resource_name=req.name,
proxmox_server_id=server.id,
proxmox_vmid=new_vmid,
proxmox_node=req.node,
allocated_by=username,
notes=f"Manually assigned for ISO install of VM {req.name}",
)
if req.ipam_network_id:
try:
network_id = req.ipam_network_id
if ipam_allocation and hasattr(ipam_allocation, "network_id"):
network_id = ipam_allocation.network_id
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if net:
if net.network and "/" in net.network:
network_mask = net.network.split("/")[1]
if not gateway and net.gateway:
gateway = net.gateway
except Exception as e:
logger.warning(f"Could not get network info from IPAM: {e}")
# Create VM
_update_task(task_id, "running", f"Создание ВМ VMID {new_vmid}...", 40,
vmid=new_vmid, node=req.node)
upid = client.create_vm_from_iso(
node=req.node,
vmid=new_vmid,
name=req.name,
memory=req.memory,
cores=req.cores,
disk_storage=req.storage,
disk_size=req.disk,
iso_volid=req.iso,
bridge=req.bridge,
ostype=req.ostype,
disk_bus=req.disk_bus,
net_model=req.net_model,
bios=req.bios,
tpm=req.tpm,
extra_iso_volid=req.extra_iso,
onboot=req.onboot,
description=f"Created for ISO install ({req.iso}) by {username}",
)
if not upid:
_update_task(task_id, "failed", "Ошибка создания ВМ", 40,
error="Proxmox не вернул UPID задачи создания ВМ")
return
_update_task(task_id, "running", "Ожидание создания диска...", 60,
vmid=new_vmid, node=req.node)
if not client.wait_for_task(req.node, upid, timeout=300):
_update_task(task_id, "failed", "Таймаут создания ВМ", 60,
error="Создание ВМ заняло слишком долго (>5 мин)")
return
if req.start_after_create:
_update_task(task_id, "running", "Запуск ВМ...", 85, vmid=new_vmid, node=req.node)
client.start_vm(req.node, new_vmid)
_update_task(task_id, "running", "Сохранение в базе данных...", 95,
vmid=new_vmid, node=req.node)
save_vm_instance(
db=db,
server_id=server.id,
vmid=new_vmid,
node=req.node,
vm_type="qemu",
name=req.name,
cores=req.cores,
memory=req.memory,
disk_size=req.disk,
ip_address=allocated_ip,
ip_prefix=int(network_mask),
gateway=gateway,
template_id=None,
template_name=req.iso,
description=f"Created for ISO install from {req.iso}",
owner_id=owner_id or user_id,
)
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=new_vmid,
username=username,
resource_name=req.name,
server_id=server.id,
server_name=server.name,
node_name=req.node,
details={
"iso": req.iso,
"cores": req.cores,
"memory": req.memory,
"disk": req.disk,
"bios": req.bios,
"ip_address": allocated_ip,
},
success=True,
)
done_step = "ВМ создана — откройте консоль для установки ОС"
if allocated_ip:
done_step = f"ВМ создана, IP зарезервирован: {allocated_ip}/{network_mask}"
_update_task(task_id, "completed", done_step, 100, vmid=new_vmid, node=req.node)
logger.info(f"[ISO VM] Task #{task_id}: {req.name} (VMID {new_vmid}) created by {username}")
except Exception as e:
err = str(e)
logger.error(f"[ISO VM] Task #{task_id} failed: {err}")
try:
if ipam_allocation is not None:
IPAMService(db).release_ip(ipam_allocation.id)
logger.info(f"[ISO VM] Task #{task_id}: Released IPAM IP {ipam_allocation.ip_address} on failure")
except Exception as ipam_err:
logger.warning(f"[ISO VM] Task #{task_id}: Could not release IPAM IP: {ipam_err}")
try:
if server:
LoggingService.log_proxmox_action(
db=db,
action="create",
resource_type="vm",
resource_id=new_vmid,
username=username,
resource_name=req.name,
server_id=server.id,
server_name=server.name,
node_name=req.node,
details={"iso": req.iso},
success=False,
error_message=err,
)
except Exception:
pass
_update_task(task_id, "failed", f"Ошибка: {err[:150]}", 0, error=err)
finally:
db.close()
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.post("/api/vm/create-from-iso", response_model=ISOVMCreateResponse, status_code=202)
async def create_vm_from_iso(
req: ISOVMCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:create")),
):
"""
Асинхронное создание пустой KVM ВМ с примонтированным установочным ISO.
Возвращает task_id — прогресс идёт тем же каналом, что и деплой из шаблона.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
if not req.name.strip():
raise HTTPException(status_code=400, detail="Укажите имя ВМ")
if not req.node:
raise HTTPException(status_code=400, detail="Укажите ноду для размещения ВМ")
if not req.iso:
raise HTTPException(status_code=400, detail="Выберите установочный ISO-образ")
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:
raise HTTPException(status_code=400, detail=f"Модель сетевой карты должна быть одной из: {', '.join(NET_MODELS)}")
if req.bios not in BIOS_TYPES:
raise HTTPException(status_code=400, detail=f"BIOS должен быть одним из: {', '.join(BIOS_TYPES)}")
if req.disk < 1:
raise HTTPException(status_code=400, detail="Размер диска должен быть не меньше 1 GB")
# Resolve owner (admin may create the instance for another user)
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
instance_owner_id = current_user.id
if req.owner_id and req.owner_id != current_user.id:
if not is_admin:
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
owner = db.query(User).filter(User.id == req.owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
# Enforce per-user resource quota (no-op if owner has no quota set)
from ...services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=req.cores, add_memory_mb=req.memory, add_disk_gb=req.disk)
task = DeployTask(
status="pending",
kind="vm_iso",
step="В очереди...",
progress=0,
name=req.name,
server_id=server.id,
user_id=current_user.id,
created_at=_utcnow(),
)
db.add(task)
db.commit()
db.refresh(task)
task_id = task.id
loop = asyncio.get_event_loop()
loop.run_in_executor(
_iso_vm_executor,
_do_iso_vm_create,
task_id,
req,
current_user.id,
current_user.username,
instance_owner_id,
)
return ISOVMCreateResponse(task_id=task_id, status="pending", name=req.name)

View File

@@ -463,11 +463,10 @@ async def deploy_lxc_from_template(
from ...services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=req.cores, add_memory_mb=req.memory, add_disk_gb=req.disk)
if req.ssh_key_ids:
from ..ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)
existing = (req.ssh_keys or "").strip()
req.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
from ..ssh_keys import build_deploy_ssh_keys
req.ssh_keys = build_deploy_ssh_keys(
db, current_user, instance_owner_id, req.ssh_key_ids, req.ssh_keys,
) or None
# Create task record
task = DeployTask(

View File

@@ -28,6 +28,210 @@ def _ok_or_400(result: dict):
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
# ==================== Power: reboot / shutdown ====================
@router.post("/api/servers/{server_id}/nodes/{node}/status")
async def node_power_action(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:power")),
):
"""Управление питанием ноды: reboot | shutdown (как в UI Proxmox)."""
data = await request.json()
command = data.get("command")
if command not in ("reboot", "shutdown"):
raise HTTPException(status_code=400, detail="command must be reboot or shutdown")
client = _resolve_client(db, server_id)
try:
return _ok_or_400(await _run_in_executor(lambda: client.node_power_action(node, command)))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error {command} node {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Syslog ====================
@router.get("/api/servers/{server_id}/nodes/{node}/syslog")
async def node_syslog(
server_id: int,
node: str,
limit: int = 500,
start: int = 0,
service: str = None,
since: str = None,
until: str = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"syslog": await _run_in_executor(
lambda: client.get_node_syslog(node, limit, start, service, since, until))})
except Exception as e:
logger.error(f"Error getting syslog on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Task history ====================
@router.get("/api/servers/{server_id}/nodes/{node}/tasks")
async def node_tasks(
server_id: int,
node: str,
limit: int = 100,
start: int = 0,
errors: bool = False,
typefilter: str = None,
userfilter: str = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"tasks": await _run_in_executor(
lambda: client.get_node_tasks(node, limit, start, errors, typefilter, userfilter))})
except Exception as e:
logger.error(f"Error getting tasks on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/servers/{server_id}/nodes/{node}/tasks/{upid}/log")
async def node_task_log(
server_id: int,
node: str,
upid: str,
limit: int = 500,
start: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={
"log": await _run_in_executor(lambda: client.get_node_task_log(node, upid, limit, start)),
"status": await _run_in_executor(lambda: client.get_node_task_status(node, upid)),
})
except Exception as e:
logger.error(f"Error getting task log {upid} on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== System: DNS / hosts / time ====================
@router.get("/api/servers/{server_id}/nodes/{node}/dns")
async def get_node_dns(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"dns": await _run_in_executor(lambda: client.get_node_dns(node))})
except Exception as e:
logger.error(f"Error getting DNS on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/nodes/{node}/dns")
async def set_node_dns(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
try:
return _ok_or_400(await _run_in_executor(lambda: client.set_node_dns(
node, data.get("search"), data.get("dns1"), data.get("dns2"), data.get("dns3"))))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting DNS on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/servers/{server_id}/nodes/{node}/hosts")
async def get_node_hosts(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"hosts": await _run_in_executor(lambda: client.get_node_hosts(node))})
except Exception as e:
logger.error(f"Error getting hosts on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/nodes/{node}/hosts")
async def set_node_hosts(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
hosts_data = data.get("data")
if hosts_data is None:
raise HTTPException(status_code=400, detail="data is required")
try:
return _ok_or_400(await _run_in_executor(
lambda: client.set_node_hosts(node, hosts_data, data.get("digest"))))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting hosts on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/servers/{server_id}/nodes/{node}/time")
async def get_node_time(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"time": await _run_in_executor(lambda: client.get_node_time(node))})
except Exception as e:
logger.error(f"Error getting time on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/nodes/{node}/time")
async def set_node_timezone(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
timezone = data.get("timezone")
if not timezone:
raise HTTPException(status_code=400, detail="timezone is required")
try:
return _ok_or_400(await _run_in_executor(lambda: client.set_node_timezone(node, timezone)))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting timezone on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== Services ====================
@router.get("/api/servers/{server_id}/nodes/{node}/services")
@@ -142,6 +346,69 @@ async def set_apt_repository(
raise HTTPException(status_code=500, detail=str(e))
# ==================== TLS certificates ====================
@router.get("/api/servers/{server_id}/nodes/{node}/certificates")
async def node_certificates(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
return JSONResponse(content={"certificates": await _run_in_executor(
lambda: client.get_node_certificates(node))})
except Exception as e:
logger.error(f"Error getting certificates on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/servers/{server_id}/nodes/{node}/certificates")
async def upload_node_certificate(
server_id: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:manage")),
):
"""Загрузить пользовательский сертификат pveproxy (PEM cert + key)."""
client = _resolve_client(db, server_id)
data = await request.json()
certificates = data.get("certificates")
if not certificates:
raise HTTPException(status_code=400, detail="certificates (PEM) is required")
try:
return _ok_or_400(await _run_in_executor(lambda: client.upload_node_certificate(
node, certificates, data.get("key"),
bool(data.get("force", True)), bool(data.get("restart", True)))))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading certificate on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/servers/{server_id}/nodes/{node}/certificates")
async def delete_node_certificate(
server_id: int,
node: str,
restart: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("node:manage")),
):
"""Удалить пользовательский сертификат (вернуть самоподписанный)."""
client = _resolve_client(db, server_id)
try:
return _ok_or_400(await _run_in_executor(
lambda: client.delete_node_certificate(node, restart)))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting certificate on {node}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/api/servers/{server_id}/nodes/{node}/apt/repositories")
async def add_standard_repository(
server_id: int,

View File

@@ -8,21 +8,55 @@ import ssl
import asyncio
import httpx
import websockets
from concurrent.futures import ThreadPoolExecutor, as_completed
RESOURCES_MAX_WORKERS = 8 # parallel Proxmox fetches for /api/resources/all
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from app.schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
from ...proxmox.token_provisioning import (
TokenProvisionError, provision_api_token, revoke_api_token,
)
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
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)
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
can_view_all_instances, get_owned_vmids, get_visible_server_ids)
router = APIRouter()
def _attach_workspaces(db: Session, servers: list) -> None:
"""Прикрепить к каждому серверу список рабочих областей, в которых он состоит.
Пишет транзиентный атрибут ``server.workspaces`` (список словарей), который
затем читает Pydantic-схема ``ProxmoxServerResponse`` через ``from_attributes``.
"""
from ...models import WorkspaceServer, Workspace
if not servers:
return
server_ids = [s.id for s in servers]
rows = (
db.query(WorkspaceServer.server_id, Workspace.id, Workspace.name, Workspace.color)
.join(Workspace, Workspace.id == WorkspaceServer.workspace_id)
.filter(WorkspaceServer.server_id.in_(server_ids))
.all()
)
mapping: dict[int, list] = {}
for sid, wid, wname, wcolor in rows:
mapping.setdefault(sid, []).append({"id": wid, "name": wname, "color": wcolor})
for s in servers:
s.workspaces = mapping.get(s.id, [])
# ==================== Proxmox Server CRUD ====================
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
@@ -31,52 +65,17 @@ def list_proxmox_servers(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
):
"""Получить список всех Proxmox серверов (фильтруется по активному workspace)"""
from ...api.workspaces import get_workspace_server_ids
from ...models import WorkspaceUser, WorkspaceServer
is_privileged = current_user.is_admin or (
current_user.role and current_user.role.name in ('admin', 'moderator')
)
if is_privileged:
# Privileged: use workspace filter only
server_ids = get_workspace_server_ids(request, db, current_user)
query = db.query(ProxmoxServer)
if server_ids is not None:
query = query.filter(ProxmoxServer.id.in_(server_ids))
return query.all()
# Non-privileged user: must be directly assigned AND server must be in one of their workspaces
assigned_ids = {s.id for s in current_user.assigned_servers}
# Collect server IDs accessible through user's workspaces
user_ws_ids = {
r.workspace_id for r in
db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == current_user.id).all()
}
if user_ws_ids:
ws_server_ids = {
r.server_id for r in
db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id.in_(user_ws_ids)
).all()
}
else:
ws_server_ids = set()
# Intersect: directly assigned AND within user's workspaces
effective_ids = assigned_ids & ws_server_ids
# Also apply active-workspace header filter on top
ws_filter = get_workspace_server_ids(request, db, current_user)
if ws_filter is not None:
effective_ids = effective_ids & set(ws_filter)
if not effective_ids:
"""Получить список всех Proxmox серверов (фильтруется по доступу и активному workspace)"""
visible_ids = get_visible_server_ids(request, db, current_user)
if visible_ids is not None and not visible_ids:
return []
return db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(effective_ids)).all()
query = db.query(ProxmoxServer).order_by(ProxmoxServer.id)
if visible_ids is not None:
query = query.filter(ProxmoxServer.id.in_(visible_ids))
servers = query.all()
_attach_workspaces(db, servers)
return servers
@router.post("/api/servers", response_model=ProxmoxServerResponse, status_code=status.HTTP_201_CREATED)
@@ -94,20 +93,60 @@ def create_proxmox_server(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Server with name '{server_data.name}' already exists"
)
server = ProxmoxServer(**server_data.model_dump())
payload = server_data.model_dump()
# Not a column — it selects how credentials are obtained, not what is stored.
auto_token = payload.pop("auto_create_token", False)
provisioned_token: str | None = None
if auto_token:
try:
token_name, token_value = provision_api_token(
ip_address=payload["ip_address"],
port=payload.get("port"),
api_user=payload["api_user"],
password=payload.get("password"),
verify_ssl=payload.get("verify_ssl", False),
comment=f"PVEmanager ({payload['name']})",
)
except TokenProvisionError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
payload["api_token_name"] = token_name
payload["api_token_value"] = token_value
# The password stays stored (encrypted) for SSH fallbacks like pvecm
# delnode, but day-to-day API calls now go through the token.
payload["use_password"] = False
provisioned_token = token_name
server = ProxmoxServer(**payload)
db.add(server)
db.commit()
try:
db.commit()
except Exception:
db.rollback()
# The secret was never persisted, so the token on the node is unusable —
# remove it instead of leaving an orphan behind.
if provisioned_token:
revoke_api_token(
ip_address=payload["ip_address"],
port=payload.get("port"),
api_user=payload["api_user"],
password=payload.get("password"),
token_name=provisioned_token,
verify_ssl=payload.get("verify_ssl", False),
)
raise
db.refresh(server)
# Сразу проверяем подключение, чтобы статус is_online отобразился без задержки
# в 30 секунд (период monitoring_worker.run_server_availability_check).
try:
client = _get_proxmox_client(server)
if client.is_connected():
ok, error_kind, check_error = client.check_connection()
if ok:
server.update_status(True)
else:
server.update_status(False, "Failed to connect")
server.update_status(False, check_error or "Failed to connect", error_kind)
db.commit()
db.refresh(server)
except Exception as e:
@@ -158,6 +197,7 @@ def get_proxmox_server(
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
_attach_workspaces(db, [server])
return server
@@ -227,6 +267,14 @@ def update_proxmox_server(
# Если изменились учётные данные - очищаем кеш
update_data = server_data.model_dump(exclude_unset=True)
# Пустая строка в полях учётных данных означает "не менять" (клиент
# присылает их пустыми, т.к. секреты не возвращаются с бэкенда) —
# иначе она затирает реальный токен/пароль и сервер уходит в offline.
for cred_field in ('api_token_name', 'api_token_value', 'password'):
if update_data.get(cred_field) == '':
update_data.pop(cred_field)
if any(key in update_data for key in ['password', 'api_token_name', 'api_token_value', 'api_user']):
clear_server_cache(server.ip_address)
logger.info(f"Cleared cache for server {server.ip_address} due to credential update")
@@ -250,10 +298,11 @@ def update_proxmox_server(
if creds_changed:
try:
client = _get_proxmox_client(server)
if client.is_connected():
ok, error_kind, check_error = client.check_connection()
if ok:
server.update_status(True)
else:
server.update_status(False, "Failed to connect")
server.update_status(False, check_error or "Failed to connect", error_kind)
db.commit()
db.refresh(server)
if server.is_online:
@@ -382,7 +431,9 @@ def test_proxmox_credentials(
):
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
try:
if payload.use_password:
# auto_create_token still authenticates by password at this point — the
# token only gets created when the server is actually saved.
if payload.use_password or payload.auto_create_token:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
@@ -406,6 +457,70 @@ def test_proxmox_credentials(
return {"success": False, "status": "error", "message": str(e)}
@router.post("/api/servers/{server_id}/provision-token", status_code=status.HTTP_200_OK)
def provision_server_token(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:update"))
):
"""Перевести уже подключённый сервер с парольной авторизации на API-токен.
Использует сохранённый пароль сервера: подключается им один раз, создаёт
токен и переключает сервер на токен-авторизацию. Пароль остаётся в БД
(зашифрованным) — он нужен для SSH-фолбэков вроде pvecm delnode.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
if not server.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Server has no stored password — re-add it with credentials to create a token",
)
try:
token_name, token_value = provision_api_token(
ip_address=server.ip_address,
port=server.port,
api_user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
comment=f"PVEmanager ({server.name})",
)
except TokenProvisionError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
previous_token = server.api_token_name
server.api_token_name = token_name
server.api_token_value = token_value
server.use_password = False
try:
db.commit()
except Exception:
db.rollback()
revoke_api_token(
ip_address=server.ip_address, port=server.port, api_user=server.api_user,
password=server.password, token_name=token_name, verify_ssl=server.verify_ssl,
)
raise
db.refresh(server)
# Cached connections still carry the old credentials.
try:
from ...proxmox.client import clear_server_cache
clear_server_cache(server.ip_address)
except Exception as e:
logger.debug(f"Failed to clear connection cache for {server.name}: {e}")
logger.info(
f"User {current_user.username} switched server '{server.name}' to API token "
f"'{server.api_user}!{token_name}'"
+ (f" (previous token: {previous_token})" if previous_token else "")
)
return {"success": True, "token_name": token_name}
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
def test_proxmox_connection(
server_id: int,
@@ -478,7 +593,15 @@ def get_server_resources(
# Фильтруем шаблоны - они не должны считаться как VM
vms = [vm for vm in resources.get('vms', []) if not vm.get('template', 0)]
containers = [ct for ct in resources.get('containers', []) if not ct.get('template', 0)]
# Изоляция по владельцу: обычный пользователь видит на ноде только свои
# инстансы. Живые данные Proxmox не содержат owner_id, поэтому сверяемся
# с локальным кэшем vm_instances по vmid.
if not can_view_all_instances(current_user):
owned = get_owned_vmids(db, current_user, server_id)
vms = [vm for vm in vms if vm.get('vmid') in owned]
containers = [ct for ct in containers if ct.get('vmid') in owned]
# Получаем uptime ноды
node_uptime = None
try:
@@ -513,77 +636,117 @@ def get_server_resources(
@router.get("/api/resources/all")
def get_all_resources(
db: Session = Depends(get_db),
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view"))
):
"""API для получения всех ресурсов со всех Proxmox серверов"""
proxmox_servers = db.query(ProxmoxServer).all()
"""API для получения ресурсов со всех видимых пользователю Proxmox серверов.
Набор серверов фильтруется так же, как в GET /api/servers: по доступу
пользователя и по активной рабочей области. Иначе агрегаты (total_vms,
total_containers) считались бы по всей панели, а не по текущей области.
"""
visible_ids = get_visible_server_ids(request, db, current_user)
if visible_ids is not None and not visible_ids:
return JSONResponse(content={"servers": [], "total_vms": 0, "total_containers": 0})
# order_by: without it Postgres returns heap order, which shifts every time
# update_status rewrites a row — the server list would reshuffle per request.
servers_q = db.query(ProxmoxServer).order_by(ProxmoxServer.id)
if visible_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(visible_ids))
proxmox_servers = servers_q.all()
# Обычный пользователь видит только свои инстансы во всех агрегатах.
is_privileged = can_view_all_instances(current_user)
all_resources = {
"servers": [],
"total_vms": 0,
"total_containers": 0
}
# Credentials are read here, on the request thread — the worker threads get
# plain kwargs so they never touch the ORM (Session is not thread-safe and a
# lazy load from a worker would blow up mid-request).
fetch_args = {}
for server in proxmox_servers:
try:
logger.info(f"Getting resources from server {server.name}, use_password={server.use_password}")
# Создаём клиента для каждого сервера независимо
if server.use_password:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
else:
logger.info(f"Using API token for {server.name}: user={server.api_user}")
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl
)
vms = resources.get('vms', [])
containers = resources.get('containers', [])
logger.info(f"Got {len(vms)} VMs and {len(containers)} containers from {server.name}")
server.update_status(True)
db.commit()
logger.info(f"Getting resources from server {server.name}, use_password={server.use_password}")
common = dict(host=server.ip_address, user=server.api_user, verify_ssl=server.verify_ssl)
if server.use_password:
fetch_args[server.id] = {**common, 'password': server.password}
else:
logger.info(f"Using API token for {server.name}: user={server.api_user}")
fetch_args[server.id] = {
**common,
'token_name': server.api_token_name,
'token_value': server.api_token_value,
}
# One slow or unreachable server must not hold up the rest: total wait is the
# slowest server, not the sum of all of them.
fetched: dict[int, dict] = {}
errors: dict[int, str] = {}
if proxmox_servers:
with ThreadPoolExecutor(max_workers=min(RESOURCES_MAX_WORKERS, len(proxmox_servers))) as pool:
futures = {
pool.submit(get_proxmox_resources, **fetch_args[s.id]): s
for s in proxmox_servers
}
for future in as_completed(futures):
server = futures[future]
try:
fetched[server.id] = future.result()
except Exception as e:
logger.error(f"Error getting resources from server {server.name} ({server.ip_address}): {e}")
errors[server.id] = str(e)
# Assemble in the original server order — the response must not reshuffle
# depending on which cluster happened to answer first.
for server in proxmox_servers:
if server.id in errors:
server.update_status(False, errors[server.id])
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"vms": vms,
"containers": containers,
"vms_count": len(vms),
"containers_count": len(containers)
})
all_resources["total_vms"] += len(vms)
all_resources["total_containers"] += len(containers)
except Exception as e:
logger.error(f"Error getting resources from server {server.name} ({server.ip_address}): {e}")
server.update_status(False, str(e))
db.commit()
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"error": str(e),
"error": errors[server.id],
"vms": [],
"containers": [],
"vms_count": 0,
"containers_count": 0
})
# Продолжаем обработку следующего сервера
continue
resources = fetched.get(server.id) or {}
vms = resources.get('vms', [])
containers = resources.get('containers', [])
# Изоляция по владельцу (живые данные Proxmox не содержат owner_id).
if not is_privileged:
owned = get_owned_vmids(db, current_user, server.id)
vms = [vm for vm in vms if vm.get('vmid') in owned]
containers = [ct for ct in containers if ct.get('vmid') in owned]
logger.info(f"Got {len(vms)} VMs and {len(containers)} containers from {server.name}")
server.update_status(True)
all_resources["servers"].append({
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"vms": vms,
"containers": containers,
"vms_count": len(vms),
"containers_count": len(containers)
})
all_resources["total_vms"] += len(vms)
all_resources["total_containers"] += len(containers)
db.commit() # single commit for every status update, instead of one per server
return JSONResponse(content=all_resources)

View File

@@ -10,7 +10,7 @@ import httpx
import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask, AppOperation, InstalledApp, ScriptExecution
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
@@ -202,7 +202,7 @@ def cancel_task(
def get_all_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
limit: int = Query(30, ge=1, le=200),
limit: int = Query(30, ge=1, le=500),
status_filter: str = Query(None, alias="status"),
):
"""
@@ -212,19 +212,45 @@ def get_all_tasks(
bulk_q = db.query(TaskQueue).filter(TaskQueue.user_id == current_user.id)
proxmox_q = db.query(ProxmoxTask).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)
if status_filter:
# Normalise statuses: proxmox uses running/completed/failed; bulk uses the same + pending/cancelled
bulk_q = bulk_q.filter(TaskQueue.status == status_filter)
proxmox_q = proxmox_q.filter(ProxmoxTask.status == status_filter)
deploy_q = deploy_q.filter(DeployTask.status == status_filter)
appstore_q = appstore_q.filter(AppOperation.status == status_filter)
script_q = script_q.filter(ScriptExecution.status == status_filter)
bulk_tasks = bulk_q.order_by(TaskQueue.created_at.desc()).limit(limit).all()
proxmox_tasks = proxmox_q.order_by(ProxmoxTask.created_at.desc()).limit(limit).all()
deploy_tasks = deploy_q.order_by(DeployTask.created_at.desc()).limit(limit).all()
appstore_ops = appstore_q.order_by(AppOperation.started_at.desc()).limit(limit).all()
script_executions = script_q.order_by(ScriptExecution.started_at.desc()).limit(limit).all()
# Resolve server_id -> name once for enrichment (proxmox/deploy tasks carry server_id).
server_ids = {t.server_id for t in proxmox_tasks if t.server_id} | {t.server_id for t in deploy_tasks if t.server_id}
# App Store operations carry only installed_app_id; resolve app name/node/server.
ia_ids = {o.installed_app_id for o in appstore_ops if o.installed_app_id}
ia_map = {}
if ia_ids:
for ia in db.query(InstalledApp).filter(InstalledApp.id.in_(ia_ids)).all():
ia_map[ia.id] = ia
appstore_tasks = [
o.to_task_dict(
app_name=(ia_map.get(o.installed_app_id).name if ia_map.get(o.installed_app_id) else None),
node=(ia_map.get(o.installed_app_id).node if ia_map.get(o.installed_app_id) else None),
server_id=(ia_map.get(o.installed_app_id).server_id if ia_map.get(o.installed_app_id) else None),
)
for o in appstore_ops
]
# Resolve server_id -> name once for enrichment (proxmox/deploy/appstore tasks carry server_id).
server_ids = (
{t.server_id for t in proxmox_tasks if t.server_id}
| {t.server_id for t in deploy_tasks if t.server_id}
| {t.get("server_id") for t in appstore_tasks if t.get("server_id")}
| {e.server_id for e in script_executions if e.server_id}
)
server_names = {}
if server_ids:
for sid, sname in db.query(ProxmoxServer.id, ProxmoxServer.name).filter(ProxmoxServer.id.in_(server_ids)).all():
@@ -244,7 +270,9 @@ def get_all_tasks(
all_tasks = sorted(
[_enrich(t.to_dict()) for t in bulk_tasks]
+ [_enrich(t.to_dict()) for t in proxmox_tasks]
+ [_enrich(t.to_dict()) for t in deploy_tasks],
+ [_enrich(t.to_dict()) for t in deploy_tasks]
+ [_enrich(t) for t in appstore_tasks]
+ [_enrich(e.to_task_dict()) for e in script_executions],
key=lambda t: t.get("created_at") or "",
reverse=True,
)[:limit]
@@ -273,7 +301,17 @@ def get_active_count(
DeployTask.status.in_(["pending", "running"]),
).scalar() or 0
return {"count": bulk_count + proxmox_count + deploy_count}
appstore_count = db.query(func.count(AppOperation.id)).filter(
AppOperation.user_id == current_user.id,
AppOperation.status == "running",
).scalar() or 0
script_count = db.query(func.count(ScriptExecution.id)).filter(
ScriptExecution.user_id == current_user.id,
ScriptExecution.status == "running",
).scalar() or 0
return {"count": bulk_count + proxmox_count + deploy_count + appstore_count + script_count}
# ─── Proxmox UPID task log from DB ──────────────────────────────────────────
@@ -324,6 +362,15 @@ def clear_completed_tasks(
DeployTask.user_id == current_user.id,
DeployTask.status.in_(done_statuses),
).delete(synchronize_session=False)
# App Store: чистим только историю операций (installed_apps не трогаем).
appstore_deleted = db.query(AppOperation).filter(
AppOperation.user_id == current_user.id,
AppOperation.status.in_(done_statuses),
).delete(synchronize_session=False)
script_deleted = db.query(ScriptExecution).filter(
ScriptExecution.user_id == current_user.id,
ScriptExecution.status.in_(["success", "failed"]),
).delete(synchronize_session=False)
db.commit()
return {"deleted": bulk_deleted + proxmox_deleted + deploy_deleted}
return {"deleted": bulk_deleted + proxmox_deleted + deploy_deleted + appstore_deleted + script_deleted}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
"""Scripts API package. Экспортирует агрегированный router для main.py."""
from fastapi import APIRouter
from .catalog import router as catalog_router
from .executions import router as executions_router
router = APIRouter()
router.include_router(catalog_router)
router.include_router(executions_router)

View File

@@ -0,0 +1,206 @@
"""
Каталог скриптов — CRUD своих скриптов + управление git-источниками.
GET /api/scripts — список каталога
POST /api/scripts — создать свой скрипт (source=manual)
GET /api/scripts/{id} — детали + содержимое
PUT /api/scripts/{id} — редактировать (только manual)
DELETE /api/scripts/{id} — удалить (только manual)
GET /api/scripts/repos — список git-источников
POST /api/scripts/repos — добавить git-источник
POST /api/scripts/repos/{id}/sync — запустить синк
DELETE /api/scripts/repos/{id} — удалить источник (и его скрипты)
"""
from __future__ import annotations
import re
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
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
router = APIRouter()
_SLUG_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
class ParamField(BaseModel):
name: str
label: str
type: str = "string"
required: bool = False
default: Optional[str] = None
class ScriptCreateRequest(BaseModel):
name: str
slug: str
description: Optional[str] = None
category: Optional[str] = None
target_type: str = Field(default="guest", pattern="^(node|guest)$")
interpreter: str = "/bin/bash"
content: str
params_schema: List[ParamField] = []
class ScriptUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
target_type: Optional[str] = Field(default=None, pattern="^(node|guest)$")
interpreter: Optional[str] = None
content: Optional[str] = None
params_schema: Optional[List[ParamField]] = None
class RepoCreateRequest(BaseModel):
url: str
branch: str = "main"
path_glob: str = "**/*.sh"
metadata_format: str = Field(default="header-comment", pattern="^(header-comment|community-scripts-ct)$")
enabled: bool = True
@router.get("/api/scripts")
def list_scripts(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
items = db.query(ScriptCatalog).order_by(ScriptCatalog.name).all()
return {"items": [s.to_dict() for s in items]}
@router.post("/api/scripts")
def create_script(
req: ScriptCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
if not _SLUG_RE.match(req.slug):
raise HTTPException(status_code=400, detail="Некорректный slug (a-z0-9-)")
if db.query(ScriptCatalog).filter(ScriptCatalog.slug == req.slug).first():
raise HTTPException(status_code=409, detail="Скрипт с таким slug уже существует")
script = ScriptCatalog(
name=req.name, slug=req.slug, description=req.description,
category=req.category, target_type=req.target_type,
interpreter=req.interpreter or "/bin/bash", content=req.content,
params_schema=[p.model_dump() for p in req.params_schema],
source="manual", created_by=current_user.id,
)
db.add(script)
db.commit()
db.refresh(script)
return script.to_dict(with_content=True)
@router.get("/api/scripts/repos")
def list_repos(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
items = db.query(ScriptGitRepo).order_by(ScriptGitRepo.id).all()
return {"items": [r.to_dict() for r in items]}
@router.post("/api/scripts/repos")
def create_repo(
req: RepoCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
repo = ScriptGitRepo(
url=req.url, branch=req.branch, path_glob=req.path_glob,
metadata_format=req.metadata_format, enabled=req.enabled,
)
db.add(repo)
db.commit()
db.refresh(repo)
return repo.to_dict()
@router.post("/api/scripts/repos/{repo_id}/sync")
def sync_repo(
repo_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
repo = db.get(ScriptGitRepo, repo_id)
if not repo:
raise HTTPException(status_code=404, detail="Источник не найден")
result = script_catalog_sync.sync_git_repo(db, repo)
return {"success": "error" not in result, **result}
@router.delete("/api/scripts/repos/{repo_id}")
def delete_repo(
repo_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
repo = db.get(ScriptGitRepo, repo_id)
if not repo:
raise HTTPException(status_code=404, detail="Источник не найден")
db.delete(repo)
db.commit()
return {"success": True}
@router.get("/api/scripts/{script_id}")
def get_script(
script_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
script = db.get(ScriptCatalog, script_id)
if not script:
raise HTTPException(status_code=404, detail="Скрипт не найден")
return script.to_dict(with_content=True)
@router.put("/api/scripts/{script_id}")
def update_script(
script_id: int,
req: ScriptUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
script = db.get(ScriptCatalog, script_id)
if not script:
raise HTTPException(status_code=404, detail="Скрипт не найден")
if script.source != "manual":
raise HTTPException(status_code=400, detail="Скрипты из git-источника нельзя редактировать вручную")
for field in ("name", "description", "category", "target_type", "interpreter", "content"):
value = getattr(req, field)
if value is not None:
setattr(script, field, value)
if req.params_schema is not None:
script.params_schema = [p.model_dump() for p in req.params_schema]
db.commit()
db.refresh(script)
return script.to_dict(with_content=True)
@router.delete("/api/scripts/{script_id}")
def delete_script(
script_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:manage")),
):
script = db.get(ScriptCatalog, script_id)
if not script:
raise HTTPException(status_code=404, detail="Скрипт не найден")
if script.source != "manual":
raise HTTPException(status_code=400, detail="Скрипты из git-источника удаляются вместе с источником")
db.delete(script)
db.commit()
return {"success": True}

View File

@@ -0,0 +1,176 @@
"""
Запуск скриптов и журнал выполнений.
POST /api/scripts/{id}/execute — запустить скрипт
GET /api/scripts/executions — история запусков текущего пользователя (админ — все)
GET /api/scripts/executions/{id} — детали запуска (вывод, exit code)
"""
from __future__ import annotations
import threading
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...db import SessionLocal, get_db
from ...models import ProxmoxServer, ScriptCatalog, ScriptExecution, User
from ...services import script_engine
from ...config import utcnow
router = APIRouter()
_MAX_OUTPUT = 200_000 # см. SCRIPT_EXECUTION_OUTPUT_LIMIT в models/scripts.py
def _run_execution_bg(execution_id: int, server_id: int, target_type: str,
node: Optional[str], vmid: Optional[int],
vm_type: Optional[str], rendered: str,
interpreter: str, timeout: int) -> None:
"""Фоновое выполнение скрипта: community-скрипты (build.func) идут минуты и
не помещаются в таймаут прокси/HTTP-запроса. Эндпоинт возвращает execution
со статусом `running` сразу, а результат дописывается сюда; фронтенд поллит
статус до завершения. Своя DB-сессия — сессия запроса уже закрыта.
"""
db = SessionLocal()
try:
server = db.get(ProxmoxServer, server_id)
execution = db.get(ScriptExecution, execution_id)
if not server or not execution:
logger.error(f"[scripts] bg exec {execution_id}: server/execution пропал")
return
try:
result = script_engine.execute(
server, target_type=target_type, node=node, vmid=vmid,
vm_type=vm_type, script=rendered, interpreter=interpreter,
timeout=timeout,
)
execution.status = "success" if result.success else "failed"
execution.exit_code = result.exit_code
execution.output = (result.output or "")[:_MAX_OUTPUT]
execution.error_text = result.error
except Exception as e: # защита фонового потока: не оставляем running навсегда
logger.exception(f"[scripts] bg exec {execution_id} failed: {e}")
execution.status = "failed"
execution.exit_code = -1
execution.error_text = str(e)
execution.finished_at = utcnow()
db.commit()
finally:
db.close()
class ExecuteRequest(BaseModel):
server_id: int
node: Optional[str] = None
vmid: Optional[int] = None
vm_type: Optional[str] = Field(default=None, pattern="^(qemu|lxc)$")
params: dict = {}
# Верхняя граница поднята под долгие community-скрипты (build.func ставит
# Java/OpenSearch/MongoDB и т.п. — это минуты). Выполнение фоновое, так что
# длинный таймаут не держит HTTP-запрос. 0 → взять дефолт по типу скрипта.
timeout: int = Field(default=0, ge=0, le=3600)
# Дефолтные таймауты выполнения по источнику скрипта (сек), когда клиент не
# задал timeout явно (req.timeout == 0). Community-скрипты создают LXC и тянут
# образы — им нужен большой запас; обычные скрипты остаются короткими.
_DEFAULT_TIMEOUT = 60
_COMMUNITY_TIMEOUT = 1800
def _can_admin(user: User) -> bool:
return bool(getattr(user, "is_admin", False)) or user.has_permission("script:manage")
@router.post("/api/scripts/{script_id}/execute")
def execute_script(
script_id: int,
req: ExecuteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:execute")),
):
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:
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")
# Валидация обязательных параметров по схеме скрипта.
schema = script.params_schema or []
params = dict(req.params or {})
for field in schema:
name = field.get("name")
if not name:
continue
if name not in params or params[name] in (None, ""):
if field.get("required"):
raise HTTPException(status_code=400, detail=f"Параметр '{name}' обязателен")
if field.get("default") is not None:
params[name] = field["default"]
params = {k: str(v) for k, v in params.items() if k in {f.get("name") for f in schema}}
execution = ScriptExecution(
script_id=script.id, script_name=script.name,
target_type=script.target_type, server_id=server.id,
node=req.node, vmid=req.vmid, params_used=params,
status="running", user_id=current_user.id,
)
db.add(execution)
db.commit()
db.refresh(execution)
rendered = script_engine.render_params(script.content, params)
# Эффективный таймаут: явное значение клиента, иначе дефолт по источнику
# (community-скрипты идут минуты — см. _COMMUNITY_TIMEOUT).
is_community = getattr(script, "source", None) == "community-scripts"
timeout = req.timeout or (_COMMUNITY_TIMEOUT if is_community else _DEFAULT_TIMEOUT)
# Выполнение — в фоновом потоке: долгие community-скрипты (build.func) не
# укладываются в таймаут HTTP/прокси (nginx рвёт на 60с → 504). Возвращаем
# execution со статусом `running`, фронтенд поллит его до завершения.
threading.Thread(
target=_run_execution_bg,
args=(execution.id, server.id, script.target_type, req.node, req.vmid,
req.vm_type, rendered, script.interpreter, timeout),
daemon=True,
).start()
return execution.to_dict()
@router.get("/api/scripts/executions")
def list_executions(
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
q = db.query(ScriptExecution)
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]}
@router.get("/api/scripts/executions/{execution_id}")
def get_execution(
execution_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("script:view")),
):
execution = db.get(ScriptExecution, execution_id)
if not execution:
raise HTTPException(status_code=404, detail="Запуск не найден")
if not _can_admin(current_user) and execution.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Нет доступа к этому запуску")
return execution.to_dict()

View File

@@ -451,3 +451,48 @@ def resolve_ssh_keys_for_deploy(
)
return "\n".join(k.public_key.strip() for k in keys if k.public_key).strip()
def build_deploy_ssh_keys(
db: Session,
requester: User,
instance_owner_id: int,
key_ids: Optional[List[int]] = None,
raw_keys: Optional[str] = None,
) -> str:
"""Собрать итоговый блок public-ключей для cloud-init / LXC.
Объединяет вставленные вручную ключи, выбранные из библиотеки ключи
(с проверкой прав) и профильный ключ **владельца** инстанса.
Профильный ключ берётся именно у владельца, а не у того, кто нажал
«Создать»: иначе VM, созданная админом для пользователя, получала ключ
админа, а ключ самого пользователя на неё не попадал.
"""
parts: List[str] = []
if raw_keys:
parts.append(raw_keys)
if key_ids:
parts.append(
resolve_ssh_keys_for_deploy(db, requester, key_ids, instance_owner_id)
)
owner = (
requester
if instance_owner_id == requester.id
else db.query(User).filter(User.id == instance_owner_id).first()
)
if owner and owner.ssh_public_key:
parts.append(owner.ssh_public_key)
seen: set = set()
uniq: List[str] = []
for block in parts:
for line in (block or "").splitlines():
line = line.strip()
if line and line not in seen:
seen.add(line)
uniq.append(line)
return "\n".join(uniq)

View File

@@ -754,7 +754,7 @@ def _update_deploy_task(task_id: int, status: str, step: str, progress: int,
def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
user_id: int, username: str, user_ssh_key: str,
user_id: int, username: str,
owner_id: int = None):
"""Blocking deploy logic — runs in a thread pool."""
db = SessionLocal()
@@ -918,18 +918,10 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
# Configure VM
_update_deploy_task(task_id, 'running', 'Настройка VM (CPU, RAM, Cloud-Init)...', 72, vmid=new_vmid, node=deploy_node)
# Ключи уже собраны и дедуплицированы в эндпоинте
# (build_deploy_ssh_keys): вставленные вручную + выбранные из
# библиотеки + профильный ключ владельца инстанса.
ssh_keys = deploy_data.ssh_keys or ""
if user_ssh_key:
ssh_keys = f"{ssh_keys}\n{user_ssh_key}".strip()
# Deduplicate SSH keys while preserving order
_seen_ssh: set = set()
_uniq_ssh = []
for _k in ssh_keys.splitlines():
_k = _k.strip()
if _k and _k not in _seen_ssh:
_seen_ssh.add(_k)
_uniq_ssh.append(_k)
ssh_keys = "\n".join(_uniq_ssh)
config_result = client.configure_vm(
node=deploy_node,
@@ -1084,13 +1076,11 @@ async def deploy_vm_from_template(
from ..services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
if deploy_data.ssh_key_ids:
from .ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(
db, current_user, deploy_data.ssh_key_ids, instance_owner_id
)
existing = (deploy_data.ssh_keys or "").strip()
deploy_data.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
from .ssh_keys import build_deploy_ssh_keys
deploy_data.ssh_keys = build_deploy_ssh_keys(
db, current_user, instance_owner_id,
deploy_data.ssh_key_ids, deploy_data.ssh_keys,
) or None
# Create deploy task record
task = DeployTask(
@@ -1116,7 +1106,6 @@ async def deploy_vm_from_template(
deploy_data,
current_user.id,
current_user.username,
current_user.ssh_public_key or '',
instance_owner_id,
)

View File

@@ -654,6 +654,7 @@ async def get_user_server_assignments(
"name": server.name,
"ip_address": server.ip_address,
"is_online": server.is_online,
"last_error_kind": server.last_error_kind,
"workspaces": server_workspaces,
"compatible": compatible,
"assigned": server.id in assigned_ids,
@@ -909,6 +910,46 @@ async def unlock_user(
return {"message": "User unlocked"}
@router.post("/api/users/{user_id}/disable-2fa")
async def admin_disable_two_factor(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Admin reset: forcibly disable a user's two-factor authentication.
Used when a user loses their authenticator device and all backup codes.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.two_factor_enabled and not user.two_factor_secret:
return {"message": "2FA is not enabled for this user"}
user.two_factor_enabled = False
user.two_factor_secret = None
user.two_factor_backup_codes = None
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="user_2fa_disabled",
message=f"2FA for user '{user.username}' disabled by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "Two-factor authentication disabled for user"}
@router.post("/api/users/{user_id}/terminate-sessions")
async def terminate_user_sessions(
user_id: int,
@@ -937,7 +978,9 @@ async def terminate_user_sessions(
resource_name=user.username
)
return {"message": f"Terminated {count} sessions"}
# count отдаётся отдельным полем: фронтенд склоняет фразу сам, показывать
# message пользователю нельзя — он не переводится.
return {"message": f"Terminated {count} sessions", "count": count}
# ==================== Security Settings API ====================
@@ -1126,7 +1169,7 @@ async def terminate_all_sessions(
ip_address=get_client_ip(request)
)
return {"message": f"Terminated {deleted} sessions"}
return {"message": f"Terminated {deleted} sessions", "count": deleted}
# ==================== Security Events API ====================

View File

@@ -0,0 +1,10 @@
"""
App Store — модуль каталога self-hosted приложений (1 приложение = 1 LXC + Docker Compose).
M0 (Proof of Concept): изолированный backend-каркас install-пайплайна
`clone → push → exec → health-check` для одного приложения. НЕ подключается к
FastAPI (роутеры не регистрируются) — запускается вручную через CLI
(`python -m app.appstore.cli ...`) для ручной проверки схемы на реальном Proxmox.
См. docs/appstore-poc.md.
"""

123
backend/app/appstore/cli.py Normal file
View File

@@ -0,0 +1,123 @@
"""
Ручной раннер M0 PoC. Запускается в окружении бэкенда (с доступом к БД панели):
# установка тестового приложения из золотого шаблона:
python -m app.appstore.cli install \
--server-id 1 \
--ostemplate local:vztmpl/golden-docker_12_amd64.tar.zst \
--name poc-uptime-kuma \
--port 3001
# повторяемость — снести контейнер:
python -m app.appstore.cli teardown --server-id 1 --vmid 131
По умолчанию берётся встроенный sample uptime-kuma (samples/uptime-kuma.compose.yml).
Свой compose/env — через --compose-file / --env-file.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ..db import SessionLocal
from .pipeline import InstallSpec, PocError, poc_install, poc_teardown
_SAMPLES = Path(__file__).parent / "samples"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _parse_env(text: str) -> dict:
env = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def cmd_install(args) -> int:
compose_path = Path(args.compose_file) if args.compose_file else _SAMPLES / "uptime-kuma.compose.yml"
env_path = Path(args.env_file) if args.env_file else _SAMPLES / "uptime-kuma.env"
compose_yaml = _read(compose_path)
env_vars = _parse_env(_read(env_path)) if env_path.exists() else {}
spec = InstallSpec(
server_id=args.server_id,
ostemplate=args.ostemplate,
name=args.name,
compose_yaml=compose_yaml,
port=args.port,
env_vars=env_vars,
node=args.node,
cores=args.cores,
memory=args.memory,
disk=args.disk,
storage=args.storage,
bridge=args.bridge,
)
db = SessionLocal()
try:
res = poc_install(db, spec)
finally:
db.close()
print("\n=== PoC install OK ===")
print(f"vmid : {res.vmid}")
print(f"node : {res.node}")
print(f"ip : {res.ip}")
print(f"url : {res.url}")
print(f"status : {res.status}")
return 0 if res.status == "running" else 2
def cmd_teardown(args) -> int:
db = SessionLocal()
try:
poc_teardown(db, args.server_id, args.vmid, args.node)
finally:
db.close()
print(f"Teardown OK: vmid={args.vmid}")
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="app.appstore.cli", description="App Store M0 PoC runner")
sub = p.add_subparsers(dest="cmd", required=True)
pi = sub.add_parser("install", help="Установить тестовое приложение (clone→push→exec→health)")
pi.add_argument("--server-id", type=int, required=True)
pi.add_argument("--ostemplate", required=True, help="Золотой vztmpl, напр. local:vztmpl/golden-docker_12_amd64.tar.zst")
pi.add_argument("--name", default="poc-uptime-kuma")
pi.add_argument("--port", type=int, default=3001)
pi.add_argument("--node", default=None)
pi.add_argument("--cores", type=int, default=2)
pi.add_argument("--memory", type=int, default=2048)
pi.add_argument("--disk", type=int, default=8)
pi.add_argument("--storage", default="local-lvm")
pi.add_argument("--bridge", default="vmbr0")
pi.add_argument("--compose-file", default=None, help="Свой docker-compose.yml (по умолчанию sample uptime-kuma)")
pi.add_argument("--env-file", default=None, help="Свой .env (по умолчанию sample)")
pi.set_defaults(func=cmd_install)
pt = sub.add_parser("teardown", help="Остановить и удалить контейнер PoC")
pt.add_argument("--server-id", type=int, required=True)
pt.add_argument("--vmid", type=int, required=True)
pt.add_argument("--node", default=None)
pt.set_defaults(func=cmd_teardown)
args = p.parse_args(argv)
try:
return args.func(args)
except PocError as e:
print(f"\n[PoC FAILED] {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,238 @@
"""
Автосборка золотого LXC-шаблона (Workstream B).
Автоматизирует ручную процедуру из docs/golden-template.md:
pveam download базового Debian → pct create временного CT → установка
Docker/compose → подготовка каталогов/сети/logrotate → (опц.) проверка nginx →
vzdump в vztmpl → удаление временного CT.
Результат — volid вида "<storage>:vztmpl/golden-docker_12_<arch>.tar.zst",
готовый для APPSTORE_GOLDEN_TEMPLATE.
Переиспользует SSH-инфраструктуру пайплайна установки (root@node,
pct/pvesm/pveam/vzdump). Прогресс отдаётся через callback on_step(step, progress).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
from loguru import logger
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 .pipeline import (
PocError, StepCb, _node_ssh, _noop, _pct, _resolve_node, _run, _wait_ip,
)
# Скрипты, исполняемые ВНУТРИ контейнера через `pct exec ... bash -lc`.
# Важно: без одинарных кавычек (иначе сломают обёртку _shq в _pct).
_DOCKER_INSTALL = r"""
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl jq gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
"""
_PREP = r"""
set -e
mkdir -p /opt/app /opt/app-data
docker network inspect tipi_main_network >/dev/null 2>&1 || docker network create tipi_main_network
cat >/etc/docker/daemon.json <<EOF
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
EOF
systemctl restart docker
"""
_VALIDATE = r"""
set -e
mkdir -p /opt/app && cd /opt/app
printf "services:\n web:\n image: nginx:alpine\n ports:\n - \"80:80\"\n" > docker-compose.yml
docker compose up -d
sleep 3
curl -sf http://localhost:80 >/dev/null && echo OK
docker compose down
rm -f /opt/app/docker-compose.yml
"""
_CLEANUP = r"""
set -e
apt-get clean || true
rm -rf /var/lib/apt/lists/*
rm -f /opt/app/docker-compose.yml
"""
@dataclass
class GoldenSpec:
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"
arch: Optional[str] = None # amd64|arm64; None → settings.APPSTORE_HOST_ARCH
force: bool = False # пересобрать, даже если шаблон уже существует
vmid: Optional[int] = None # если None — берётся следующий свободный
cores: int = 2
memory: int = 2048 # МБ
swap: int = 512 # МБ
disk: int = 8 # ГБ (rootfs временного CT)
build_timeout: int = 1800 # сек на apt/docker install и vzdump
validate: bool = True # тестовый nginx (DoD ТЗ 4.2)
@dataclass
class GoldenResult:
volid: str
node: str
status: str # "created" | "exists"
def build_golden_template(db: Session, spec: GoldenSpec,
on_step: StepCb = _noop) -> GoldenResult:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == spec.server_id).first()
if not server:
raise PocError(f"Proxmox сервер id={spec.server_id} не найден")
on_step("Подключение к Proxmox...", 5)
client = _get_proxmox_client(server)
if not client.is_connected():
raise PocError("Не удалось подключиться к Proxmox")
node = _resolve_node(client, spec.node)
arch = (spec.arch or settings.APPSTORE_HOST_ARCH or "amd64").strip().lower()
target_name = f"golden-docker_12_{arch}.tar.zst"
target_volid = f"{spec.template_storage}:vztmpl/{target_name}"
ssh = _node_ssh(server, client)
if not ssh.connect():
raise PocError("SSH к ноде не установлен (нужен root-доступ к ноде)")
vmid: Optional[int] = None
created_ct = False
try:
# Идемпотентность: шаблон уже есть и не запрошена пересборка.
on_step("Проверка существующего шаблона...", 10)
existing = _run(ssh, f"pvesm list {spec.template_storage} --content vztmpl",
what="list vztmpl", timeout=60)
if target_name in existing and not spec.force:
on_step(f"Шаблон уже существует: {target_volid}", 100)
return GoldenResult(volid=target_volid, node=node, status="exists")
# 1. Базовый шаблон Debian 12 — актуальное имя из pveam (не хардкодим версию).
on_step("Поиск и загрузка базового шаблона Debian 12...", 20)
_run(ssh, "pveam update", what="pveam update", timeout=120)
base_name = _run(
ssh,
"pveam available --section system | awk '{print $2}' | "
f"grep -E '^debian-12-standard_.*_{arch}\\.tar\\.zst$' | sort -V | tail -1",
what="find debian template",
).strip()
if not base_name:
raise PocError(f"Не найден базовый шаблон debian-12-standard для арх. {arch}")
have = _run(ssh, f"pvesm list {spec.template_storage} --content vztmpl",
what="list vztmpl", timeout=60)
if base_name not in have:
_run(ssh, f"pveam download {spec.template_storage} {base_name}",
what="pveam download", timeout=600)
# 2. Временный CT из базового шаблона.
vmid = spec.vmid or get_next_vmid(db, server.id)
on_step(f"Создание временного CT {vmid}...", 30)
_run(
ssh,
f"pct create {vmid} {spec.template_storage}:vztmpl/{base_name} "
f"--hostname golden-docker --unprivileged 1 "
f"--features nesting=1,keyctl=1 "
f"--cores {spec.cores} --memory {spec.memory} --swap {spec.swap} "
f"--rootfs {spec.rootfs_storage}:{spec.disk} "
f"--net0 name=eth0,bridge={spec.bridge},ip=dhcp "
f"--nameserver \"{settings.APPSTORE_NAMESERVER}\"",
what="pct create", timeout=300,
)
created_ct = True
on_step("Запуск CT и ожидание сети...", 40)
_run(ssh, f"pct start {vmid}", what="pct start", timeout=120)
ip = _wait_ip(ssh, vmid, 120)
if not ip:
raise PocError("Временный CT не получил IP за 120с (нет сети для apt/docker)")
# 3. Установка Docker + утилит (долгий шаг).
on_step("Установка Docker и Compose...", 55)
_run(ssh, _pct(vmid, _DOCKER_INSTALL), what="install docker",
timeout=spec.build_timeout)
# 4. Каталоги, docker-сеть, logrotate.
on_step("Подготовка каталогов, сети и logrotate...", 72)
_run(ssh, _pct(vmid, _PREP), what="prepare template", timeout=300)
# 5. Проверка DoD: тестовый nginx поднимается с первой попытки.
if spec.validate:
on_step("Проверка (тестовый nginx)...", 80)
_run(ssh, _pct(vmid, _VALIDATE), what="validate nginx", timeout=300)
# 6. Очистка и остановка.
on_step("Очистка временного состояния...", 86)
_run(ssh, _pct(vmid, _CLEANUP), what="cleanup", timeout=120)
_run(ssh, f"pct stop {vmid}", what="pct stop", timeout=120)
# 7. Экспорт rootfs в vztmpl через vzdump в каталог кэша storage.
on_step("Экспорт шаблона (vzdump)...", 90)
# Используем реальное имя уже скачанного базового шаблона, а не
# выдуманное "probe": pvesm валидирует имя тома vztmpl по расширению
# (.tar.gz/.tar.xz/.tar.zst) и роняет "unable to parse directory
# volume name" на произвольной строке без такого расширения.
probe = _run(ssh, f"pvesm path {spec.template_storage}:vztmpl/{base_name}",
what="resolve vztmpl dir").strip()
cache_dir = os.path.dirname(probe)
if not cache_dir:
raise PocError(f"Не удалось определить каталог vztmpl для {spec.template_storage}")
_run(ssh, f"vzdump {vmid} --dumpdir {cache_dir} --mode stop --compress zstd",
what="vzdump export", timeout=spec.build_timeout)
on_step("Переименование в золотой шаблон...", 96)
artifact = _run(
ssh,
f"ls -t {cache_dir}/vzdump-lxc-{vmid}-*.tar.zst 2>/dev/null | head -1",
what="find vzdump artifact",
).strip()
if not artifact:
raise PocError("vzdump не создал ожидаемый архив шаблона")
_run(ssh, f"mv -f {artifact} {cache_dir}/{target_name}",
what="rename to golden")
# 8. Удаление временного CT.
on_step("Удаление временного CT...", 98)
_run(ssh, f"pct destroy {vmid} --purge 1", what="destroy temp CT", timeout=120)
created_ct = False
on_step(f"Готово: {target_volid}", 100)
return GoldenResult(volid=target_volid, node=node, status="created")
except Exception:
# При сбое пытаемся убрать недособранный временный CT, чтобы не мусорить.
if created_ct and vmid is not None:
try:
_run(ssh, f"pct stop {vmid}", what="cleanup stop", timeout=60)
except Exception:
pass
try:
_run(ssh, f"pct destroy {vmid} --purge 1", what="cleanup destroy", timeout=120)
except Exception as e:
logger.warning(f"[golden-template] cleanup destroy {vmid}: {e}")
raise
finally:
ssh.close()

View File

@@ -0,0 +1,915 @@
"""
M0 PoC install-пайплайн: clone (create from golden vztmpl) → push → exec → health-check.
Ядро переиспользует существующую инфраструктуру панели:
- `_get_proxmox_client()` / `create_lxc_container()` / `wait_for_task()` /
`start_container()` / `stop_container()` / `delete_container()` — Proxmox API;
- `SSHClient` (root@node, тот же паттерн, что в proxmox/mixins/cluster.py) — для
`pct exec` / `pct push` с полным выводом (нужно для логов и наблюдаемости шагов).
Это seed будущего services/appstore_engine.py. Прогресс отдаётся через callback
`on_step(step, progress)` — в реальном движке он станет WS-broadcast'ом.
Безопасность (S-4): содержимое docker-compose.yml и .env НЕ конкатенируется в
shell-строку. Файлы доставляются на ноду через base64→файл→`pct push`, поэтому
произвольный ввод не может вырваться в шелл. Имя приложения валидируется regex.
"""
from __future__ import annotations
import base64
import re
import secrets
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, Optional
import yaml
from loguru import logger
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
# Docker-сеть, на которую ссылаются compose-файлы Runtipi как external.
TIPI_NETWORK = "tipi_main_network"
APP_DIR = "/opt/app" # сюда кладём docker-compose.yml + .env
APP_DATA_DIR = "/opt/app-data" # значение APP_DATA_DIR для приложений
# Имя = hostname LXC: буквы/цифры/дефис, 1..63.
_NAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")
StepCb = Callable[[str, int], None]
class PocError(RuntimeError):
"""Ошибка пайплайна с человекочитаемым шагом, на котором упали."""
@dataclass
class InstallSpec:
server_id: int
ostemplate: str # golden vztmpl, напр. local:vztmpl/golden-docker_12_amd64.tar.zst
name: str # hostname / имя приложения
compose_yaml: str # содержимое docker-compose.yml
port: int = 3001 # порт для health-check
# Порт выбран пользователем явно (форма мастера), а не взят из каталога.
# Влияет на нормализацию compose: явный порт публикуется как есть (в т.ч.
# 80), а каталожные 0/80 считаются «не задано» → публикуем внутренний.
port_explicit: bool = False
env_vars: Dict[str, str] = field(default_factory=dict)
node: Optional[str] = None # если None — выбирается автоматически
vmid: Optional[int] = None # если задан — использовать его (идемпотентный повтор)
cores: int = 2
memory: int = 2048 # МБ
swap: int = 512 # МБ
disk: int = 8 # ГБ
storage: str = "local-lvm"
bridge: str = "vmbr0"
features: str = "nesting=1,keyctl=1" # обязательно для Docker в unprivileged LXC
health_timeout: int = 300 # сек
ip_timeout: int = 60 # сек ожидания IP после старта
# IPAM: статическая конфигурация сети. Если ip_config задан — используется
# вместо ip=dhcp при создании net0; static_ip — заранее известный адрес
# (тогда не ждём выдачи по DHCP).
ip_config: Optional[str] = None # напр. "ip=10.0.0.5/24,gw=10.0.0.1"
static_ip: Optional[str] = None # напр. "10.0.0.5"
source: str = "runtipi" # источник каталога: runtipi | umbrel
# Явный DNS для контейнера (через пробел). None → settings.APPSTORE_NAMESERVER —
# без этого CT наследует резолвер ноды, который может быть недоступен из сети
# контейнера (напр. Tailscale-адрес), и `docker compose up` падает на pull образов.
nameserver: Optional[str] = None
# Вспомогательные файлы приложения из репозитория каталога
# ({relpath: {"b64": ..., "exec": bool}}, см. CatalogApp.data_files) —
# доставляются в ${APP_DATA_DIR}/<relpath> ДО compose up, иначе bind-mount'ы
# на них получают пустышки и приложение падает (пустой entrypoint.sh/конфиг).
data_files: Dict[str, dict] = field(default_factory=dict)
@dataclass
class InstallResult:
vmid: int
node: str
ip: Optional[str]
url: Optional[str]
status: str
def _noop(step: str, progress: int) -> None:
logger.info(f"[appstore-poc] {progress:3d}% {step}")
# ── SSH-хелперы (root@node), идентичный паттерн cluster.py ────────────────────
def _node_ssh(server: ProxmoxServer, client) -> SSHClient:
ssh_host = (server.ip_address or server.hostname or "").split(":")[0]
return SSHClient(
hostname=ssh_host,
username="root",
password=getattr(client, "_password", None),
timeout=30, # запас на подключение; per-command таймаут задаётся в _run
)
# Таймаут по умолчанию для быстрых команд на ноде (pct exec/push, mkdir и т.п.).
# Долгие шаги (docker compose pull/up) передают увеличенный таймаут явно.
_CMD_TIMEOUT = 60
_DOCKER_TIMEOUT = 600
# Ожидание завершения задачи Proxmox на удаление CT. Долгий таймаут — при
# wipe_after_delete на LVM-хранилищах затирание диска нулями может занимать
# много минут; если сдаться раньше, VMID можно ошибочно счесть свободным и
# словить гонку блокировки конфига при немедленном пересоздании.
_TEARDOWN_WAIT_TIMEOUT = 1800
_LAYER_PROGRESS_RE = re.compile(
r"^\s*[0-9a-f]{12}\s+(Pulling|Waiting|Downloading|Verifying Checksum|"
r"Download complete|Extracting|Pull complete|Already exists)\b"
)
def _strip_pull_noise(output: str) -> str:
"""Убрать построчный progress-шум `docker compose pull/up` (на каждый слой
образа своя строка Downloading/Extracting и т.п., перерисовывающаяся много
раз). На тяжёлых многослойных образах этот шум может занять весь хвост
вывода, вытеснив из среза саму причину падения (см. _run).
"""
lines = [ln for ln in output.splitlines() if not _LAYER_PROGRESS_RE.match(ln)]
return "\n".join(lines)
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
"""Выполнить команду на ноде, вернуть stdout. Бросить PocError при exit!=0.
При таймауте execute() вернёт exit_code=-1 → бросаем PocError (установка
завершается с ошибкой, а не зависает бесконечно).
"""
output, exit_code = ssh.execute(cmd, return_exit_code=True, timeout=timeout)
if exit_code == -1:
raise PocError(f"{what}: таймаут (>{timeout}с) или обрыв SSH")
if exit_code != 0:
# Хвост, а не голова: для многословных команд (docker compose pull/up)
# реальная причина падения — последние строки, а не progress-шум
# "Pulling/Downloading" в начале. Раньше срез [:2000] обрезал именно
# причину, оставляя в UI один прогресс-бар без объяснения. Но и хвост
# может целиком утонуть в построчном progress-шуме на тяжёлых образах
# (много слоёв) — поэтому сначала вычищаем его, если после этого
# что-то остаётся.
text = (output or "").strip()
filtered = _strip_pull_noise(text).strip()
tail = filtered or text
raise PocError(f"{what}: exit={exit_code}: {tail[-4000:]}")
return output or ""
def _pct(vmid: int, inner: str) -> str:
"""pct exec в контейнере через bash -lc (inner фиксированный, без польз. ввода)."""
return f"pct exec {vmid} -- /bin/bash -lc {_shq(inner)}"
def _shq(s: str) -> str:
"""Одинарные кавычки для shell (безопасно для фиксированных строк)."""
return "'" + s.replace("'", "'\"'\"'") + "'"
def _to_int(v) -> Optional[int]:
"""Аккуратно привести значение к int (int или строка-число), иначе None."""
if isinstance(v, bool):
return None
if isinstance(v, int):
return v
if isinstance(v, str) and v.strip().isdigit():
return int(v.strip())
return None
def _find_main_service(services: Dict) -> Optional[str]:
"""Определить главный сервис Runtipi-compose.
Приоритет: сервис с `x-runtipi.is_main: true`. Если такого нет, но сервис
единственный — берём его. Иначе None (не можем однозначно определить).
"""
for name, svc in services.items():
if isinstance(svc, dict):
rt = svc.get("x-runtipi")
if isinstance(rt, dict) and rt.get("is_main"):
return name
if len(services) == 1:
return next(iter(services))
return None
def _compose_env_get(env, key: str) -> Optional[str]:
"""Достать значение переменной из секции environment (dict или list-формат)."""
if isinstance(env, dict):
v = env.get(key)
return str(v) if v is not None else None
if isinstance(env, list):
pref = key + "="
for item in env:
s = str(item)
if s.startswith(pref):
return s[len(pref):]
return None
# Сервисы рантайма Umbrel, которые не запустятся вне Umbrel и должны быть
# вырезаны из compose перед standalone-запуском в LXC.
def _is_umbrel_infra_service(name: str) -> bool:
low = name.lower()
return low == "app_proxy" or low == "tor" or low.endswith("_tor") or low.endswith("-tor")
def _strip_dangling_depends(services: Dict, removed: set) -> None:
"""Убрать depends_on-ссылки на удалённые сервисы (иначе compose up падает)."""
for svc in services.values():
if not isinstance(svc, dict):
continue
dep = svc.get("depends_on")
if isinstance(dep, list):
new = [d for d in dep if d not in removed]
if new:
svc["depends_on"] = new
else:
svc.pop("depends_on", None)
elif isinstance(dep, dict):
for r in removed:
dep.pop(r, None)
if not dep:
svc.pop("depends_on", None)
def _umbrel_main_service(services: Dict, app_host: Optional[str]) -> Optional[str]:
"""Определить главный сервис Umbrel-compose.
Приоритет — сервис, на который указывает app_proxy (APP_HOST). Затем —
единственный сервис, затем первый с образом.
"""
if not services:
return None
if app_host:
for name in services:
if name == app_host or name in app_host.split("_") or name in app_host:
return name
if len(services) == 1:
return next(iter(services))
for name, svc in services.items():
if isinstance(svc, dict) and svc.get("image"):
return name
return next(iter(services))
def _prepare_umbrel(doc: dict) -> tuple[Optional[str], Optional[int], bool]:
"""Адаптировать Umbrel-compose: снять порт из app_proxy, вырезать рантайм
Umbrel, определить главный сервис.
Возвращает (main_service, internal_port, changed).
"""
services = doc.get("services")
if not isinstance(services, dict):
return None, None, False
# Порт контейнера и целевой сервис задаёт app_proxy Umbrel.
ap = services.get("app_proxy")
internal: Optional[int] = None
app_host: Optional[str] = None
if isinstance(ap, dict):
env = ap.get("environment")
internal = _to_int(_compose_env_get(env, "APP_PORT"))
app_host = _compose_env_get(env, "APP_HOST")
# Вырезать app_proxy/tor — их образов у нас нет, они завесят/уронят up.
removed = {n for n in list(services.keys()) if _is_umbrel_infra_service(n)}
for n in removed:
services.pop(n, None)
if removed:
_strip_dangling_depends(services, removed)
main = _umbrel_main_service(services, app_host)
return main, internal, bool(removed)
def _resolve_main_and_port(doc: dict, services: Dict, source: str
) -> tuple[Optional[str], Optional[int], bool]:
"""Определить (главный_сервис, внутренний_порт, changed) по источнику."""
if source == "umbrel":
return _prepare_umbrel(doc)
# runtipi: главный сервис и порт объявлены через x-runtipi.
main = _find_main_service(services)
internal = None
if main is not None:
svc = services.get(main)
rt = svc.get("x-runtipi") if isinstance(svc, dict) else None
internal = _to_int(rt.get("internal_port")) if isinstance(rt, dict) else None
return main, internal, False
def _published_host_port(entry, host_port: Optional[int]) -> Optional[int]:
"""Хост-порт из записи `ports:` compose.
Понимает short-syntax ('8080:80', '${APP_PORT:-3001}:3001',
'127.0.0.1:8080:80') и long-syntax ({published: ..., target: ...}).
Переменную APP_PORT подставляем значением host_port — именно его кладём
в .env, т.е. так её раскроет и docker compose. Раньше '${APP_PORT...'
парсился как не-число → health-check шёл на внутренний порт, который
наружу не опубликован, и установка ложно помечалась unhealthy.
"""
env = {"APP_PORT": str(host_port)} if host_port else {}
if isinstance(entry, dict):
pub = entry.get("published")
return _to_int(_subst_env(str(pub), env)) if pub is not None else None
parts = _subst_env(str(entry), env).split(":")
return _to_int(parts[-2]) if len(parts) >= 2 else None
def _normalize_compose(compose_yaml: str, host_port: Optional[int] = None,
*, source: str = "runtipi",
port_explicit: bool = False) -> tuple[str, Optional[int]]:
"""Привести compose к самодостаточному виду для standalone-запуска в LXC.
Делает то, что в родном рантайме (Runtipi/Umbrel) выполняет их оркестратор:
1. Объявляет упомянутые, но необъявленные сети (напр. `tipi_main_network`)
как external — иначе `docker compose up` падает с «refers to undefined
network …». Сеть создаётся заранее на ноде (`docker network create`).
2. Для Umbrel: вырезает сервисы рантайма (app_proxy, tor) — их образов у нас
нет, а порт контейнера берётся из `app_proxy.environment.APP_PORT`.
3. Публикует порт главного сервиса на хост LXC. И Runtipi (порт в
`x-runtipi.internal_port`), и Umbrel (порт в app_proxy) обычно НЕ имеют
секции `ports:` — наружу его пробрасывал бы их прокси, которого у нас нет.
Без публикации приложение недоступно по IP контейнера (health-check не
проходит). Поэтому главному сервису добавляем `ports: ["<host>:<internal>"]`.
Возвращает `(normalized_yaml, published_port)`. При любой ошибке разбора —
`(compose_yaml, None)` (используем исходный compose как есть).
"""
try:
doc = yaml.safe_load(compose_yaml)
if not isinstance(doc, dict):
return compose_yaml, None
services = doc.get("services")
if not isinstance(services, dict):
return compose_yaml, None
# Источнико-специфичная подготовка (для Umbrel мутирует services).
main, internal, changed = _resolve_main_and_port(doc, services, source)
services = doc.get("services")
if not isinstance(services, dict):
return compose_yaml, None
# (1) Необъявленные сети → external.
referenced: set[str] = set()
for svc in services.values():
if not isinstance(svc, dict):
continue
nets = svc.get("networks")
if isinstance(nets, list):
referenced.update(str(n) for n in nets)
elif isinstance(nets, dict):
referenced.update(str(n) for n in nets.keys())
if referenced:
top = doc.get("networks")
if not isinstance(top, dict):
top = {}
for net in referenced:
if net not in top:
top[net] = {"external": True}
changed = True
if changed:
doc["networks"] = top
# Для Umbrel, если порт контейнера не удалось снять из app_proxy, но
# известен порт из каталога — используем его как внутренний.
if source == "umbrel" and internal is None and host_port:
internal = host_port
# (2) Публикация порта главного сервиса на хост LXC.
published_port: Optional[int] = None
svc = services.get(main) if main is not None else None
if isinstance(svc, dict):
if internal:
# Уважаем уже объявленную публикацию (не переопределяем).
if not svc.get("ports"):
# host_port из каталога, если задан и не дефолтные 80/0; иначе
# публикуем на том же номере, что слушает контейнер. Порт,
# явно выбранный пользователем в мастере, уважаем всегда
# (в т.ч. 80 — для каталога это «не задано», для юзера нет).
pub = host_port if (host_port and (port_explicit or host_port not in (0, 80))) else internal
svc["ports"] = [f"{pub}:{internal}"]
published_port = pub
changed = True
else:
ports = svc.get("ports")
if isinstance(ports, list) and ports:
published_port = _published_host_port(ports[0], host_port) or internal
elif svc.get("ports"):
# Внутренний порт неизвестен, но публикация уже есть в compose.
ports = svc.get("ports")
if isinstance(ports, list) and ports:
published_port = _published_host_port(ports[0], host_port)
if not changed:
return compose_yaml, published_port
return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), published_port
except Exception as e:
logger.warning(f"[appstore-poc] normalize compose failed, using raw: {e}")
return compose_yaml, None
def _platform_env(env: Dict[str, str], ip: str, source: str) -> Dict[str, str]:
"""Заполнить IP-зависимые платформенные переменные окружения (ТЗ 5.3).
Общие: APP_DOMAIN/APP_HOST/APP_IP. runtipi → LOCAL_DOMAIN. umbrel →
DEVICE_DOMAIN_NAME/DEVICE_HOSTNAME/APP_SEED (частые ссылки в их compose).
"""
env.setdefault("APP_DOMAIN", ip)
env.setdefault("APP_HOST", ip)
env["APP_IP"] = ip
env.setdefault("APP_DATA_DIR", APP_DATA_DIR) # некоторые compose ссылаются на неё в volumes:
if source == "umbrel":
env.setdefault("DEVICE_DOMAIN_NAME", f"{ip}.local")
env.setdefault("DEVICE_HOSTNAME", "umbrel")
env.setdefault("APP_SEED", "pvemanager")
# Umbrel генерирует APP_PASSWORD сам (deterministic app password) —
# compose-файлы ссылаются на неё для паролей/ключей. Обычно её задаёт
# build_env движка (чтобы значение сохранилось и показалось
# пользователю); здесь — страховка для standalone-запуска пайплайна.
env.setdefault("APP_PASSWORD", secrets.token_hex(16))
else:
env.setdefault("LOCAL_DOMAIN", "tipi.local") # стандартная переменная Runtipi
return env
_VAR_RE = re.compile(
r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)"
)
def _subst_env(value: str, env: Dict[str, str]) -> str:
"""Мини-версия подстановки переменных docker compose:
${VAR}, ${VAR-def}, ${VAR:-def}, $VAR → env[VAR] | default | ''.
"""
def _rep(m: re.Match) -> str:
var = m.group(1) or m.group(3)
val = env.get(var)
if val:
return val
return m.group(2) or ""
return _VAR_RE.sub(_rep, value)
def _bind_mount_hosts(compose_yaml: str, app_dir: str, env: Dict[str, str]) -> list:
"""Хост-пути bind-mount'ов из `volumes:` всех сервисов (без именованных volume).
Официальные образы (mariadb, postgres и т.п.) в контейнере обычно пишут в
точку монтирования от имени непривилегированного пользователя (напр.
mysql, uid 999). Docker при первом `up` создаёт отсутствующую хост-директорию
сам — но от root, с 0755, поэтому такой пользователь получает Permission
denied. Возвращаем список путей, чтобы pre-create их с правами 777 (ТЗ:
изоляция на уровне LXC, разграничение владельца внутри контейнера не
требуется — важна лишь возможность писать).
"""
try:
doc = yaml.safe_load(compose_yaml)
except Exception:
return []
if not isinstance(doc, dict):
return []
services = doc.get("services")
if not isinstance(services, dict):
return []
hosts: list = []
for svc in services.values():
if not isinstance(svc, dict):
continue
vols = svc.get("volumes")
if not isinstance(vols, list):
continue
for v in vols:
if not isinstance(v, str) or ":" not in v:
continue
# Именованный volume ("dbdata:/var/lib/mysql") — без '/', '.', '~', '$'
# в начале; управляется Docker'ом, пропускаем.
if not (v.startswith(("/", ".", "~", "$"))):
continue
# Подстановка ДО split(':'): внутри ${VAR:-default} есть ':',
# который иначе рвёт строку не на границе source:target.
resolved = _subst_env(v, env).split(":", 1)[0]
if not resolved:
continue
if not resolved.startswith("/"):
resolved = f"{app_dir}/{resolved.lstrip('./')}"
hosts.append(resolved)
# uniq, сохраняя порядок
return list(dict.fromkeys(hosts))
def _is_file_mount(source: str) -> bool:
"""Эвристика: source похож на файл, а не директорию (по имени — есть точка,
напр. `.env`, `config.yml`, `db.sqlite`). Docker при отсутствующем source
создаёт директорию — для файловых bind-mount'ов (напр. AnythingLLM монтирует
`.env` как файл) это ломает `docker compose up` ошибкой "not a directory:
Are you trying to mount a directory onto a file?".
Скрытые имена (`.config`, `.n8n`, `.ssh`) — почти всегда ДИРЕКТОРИИ:
ведущая точка не считается расширением. Файлы среди них — только `.env*`.
"""
name = source.rsplit("/", 1)[-1]
if name.startswith("."):
return name.lower().startswith(".env")
return "." in name
def _prepare_bind_mounts(ssh: SSHClient, vmid: int, compose_yaml: str,
app_dir: str, env: Dict[str, str]) -> None:
"""Pre-create хост-путей bind-mount'ов с правами 777/666 (см. _bind_mount_hosts).
Директории — `mkdir -p`; файлоподобные source (см. _is_file_mount) — `touch`
родительский путь как файл, иначе Docker создаст там директорию сам.
"""
hosts = _bind_mount_hosts(compose_yaml, app_dir, env)
if not hosts:
return
dirs = [h for h in hosts if not _is_file_mount(h)]
files = [h for h in hosts if _is_file_mount(h)]
cmds = []
if dirs:
q = " ".join(_shq(h) for h in dirs)
cmds.append(f"mkdir -p {q} && chmod -R 777 {q}")
for f in files:
parent = f.rsplit("/", 1)[0] if "/" in f else "."
qf, qp = _shq(f), _shq(parent)
# rmdir — самовосстановление после старого бага: если source уже был
# ошибочно создан Docker'ом как ПУСТАЯ директория в прошлой попытке,
# убираем её перед touch; иначе Docker не может смонтировать файл поверх
# директории ("not a directory: ... mount a directory onto a file").
# Непустую директорию не трогаем (там могут быть данные приложения —
# ошибка эвристики не должна приводить к потере данных).
# chmod 777, а не 666: доставленные из каталога скрипты (entrypoint.sh
# и т.п.) должны сохранить/получить exec-бит, иначе контейнер падает
# с "permission denied" на entrypoint.
cmds.append(
f"mkdir -p {qp}; if [ -d {qf} ]; then rmdir {qf} 2>/dev/null || true; fi; "
f"[ -e {qf} ] || touch {qf}; [ -d {qf} ] || chmod 777 {qf}"
)
_run(ssh, _pct(vmid, " && ".join(cmds)), what="prepare bind-mount dirs")
def _push_file(ssh: SSHClient, vmid: int, content, dest: str, *,
perms: Optional[str] = None) -> None:
"""Доставить файл в контейнер без shell-инъекций: base64 → файл на ноде → pct push.
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")
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")
# Имя relpath data-файла: только безопасные символы (dest не квотируется в
# `pct push`), без '..' и ведущих '/' — защита от path traversal и инъекций.
_DATA_RELPATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/\-@]*$")
# Плейсхолдер Runtipi-шаблона: {{VAR}} / {{ VAR }} в файлах data/*.template.
_TPL_VAR_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
def _render_template(blob: bytes, env: Dict[str, str]) -> bytes:
"""Рендер Runtipi-шаблона: {{VAR}} → env[VAR] (отсутствующие → '')."""
text = blob.decode("utf-8", "replace")
text = _TPL_VAR_RE.sub(lambda m: str(env.get(m.group(1), "")), text)
return text.encode("utf-8")
def _push_data_files(ssh: SSHClient, vmid: int, data_files: Dict[str, dict],
env: Dict[str, str]) -> None:
"""Доставить файлы приложения из каталога в ${APP_DATA_DIR}/<relpath>.
Compose bind-mount'ит их (напр. ${APP_DATA_DIR}/data/nginx.conf,
${APP_DATA_DIR}/entrypoint.sh) — без доставки на их месте оказались бы
пустые файлы-заглушки, и приложение падает на старте. Exec-бит
сохраняется (скрипты); прочим файлам даём 666 (владелец внутри
контейнера не разграничивается — см. _bind_mount_hosts).
Файлы `*.template` (конвенция Runtipi) рендерятся ({{VAR}} → env) и
кладутся под именем без суффикса — именно его монтирует compose.
"""
safe: Dict[str, bytes] = {}
execs: Dict[str, bool] = {}
for relpath, meta in (data_files or {}).items():
if not isinstance(meta, dict) or not meta.get("b64"):
continue
if not _DATA_RELPATH_RE.match(relpath) or ".." in relpath:
logger.warning(f"[appstore-poc] skip data file with unsafe path: {relpath!r}")
continue
try:
blob = base64.b64decode(meta["b64"])
except Exception:
logger.warning(f"[appstore-poc] skip data file {relpath}: битый base64")
continue
if relpath.endswith(".template"):
relpath = relpath[: -len(".template")]
blob = _render_template(blob, env)
safe[relpath] = blob
execs[relpath] = bool(meta.get("exec"))
if not safe:
return
# Заранее создаём все родительские директории одним pct exec.
dirs = sorted({f"{APP_DATA_DIR}/{rp}".rsplit("/", 1)[0] for rp in safe})
_run(ssh, _pct(vmid, "mkdir -p " + " ".join(_shq(d) for d in dirs)),
what="mkdir data-file dirs")
for relpath, blob in safe.items():
_push_file(ssh, vmid, blob, f"{APP_DATA_DIR}/{relpath}",
perms="0777" if execs[relpath] else "0666")
def _env_file_value(v) -> str:
"""Значение для строки .env: одна строка на переменную + кавычение.
dotenv-парсер docker compose считает ` #` в незакавыченном значении началом
inline-комментария и обрезает хвост — пароль вида "pa#ss" молча превращался
в "pa". Такие значения (а также с ведущими/хвостовыми пробелами) оборачиваем
в двойные кавычки. Остальные оставляем как есть, чтобы не менять поведение
подстановки ($VAR) для существующих приложений.
"""
s = str(v).replace("\r", " ").replace("\n", " ")
if "#" in s or s != s.strip():
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
return s
def _env_file_content(env: Dict[str, str]) -> str:
return "".join(f"{k}={_env_file_value(v)}\n" for k, v in env.items())
def _wait_ip(ssh: SSHClient, vmid: int, timeout: int) -> Optional[str]:
deadline = time.time() + timeout
while time.time() < deadline:
out, code = ssh.execute(f"pct exec {vmid} -- hostname -I", return_exit_code=True)
if code == 0 and out and out.strip():
ip = out.strip().split()[0]
if ip and not ip.startswith("127."):
return ip
time.sleep(3)
return None
# ── Выбор ноды (как в _do_lxc_deploy) ─────────────────────────────────────────
def _resolve_node(client, requested: Optional[str]) -> str:
if requested:
return requested
nodes = client.get_nodes()
if not nodes:
raise PocError("Не найдено нод Proxmox")
for n in nodes:
try:
for s in client.proxmox.nodes(n["node"]).storage.get():
if "vztmpl" in s.get("content", ""):
return n["node"]
except Exception:
pass
return nodes[0]["node"]
# ── Основной пайплайн ─────────────────────────────────────────────────────────
def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> InstallResult:
if not _NAME_RE.match(spec.name):
raise PocError(f"Недопустимое имя приложения: {spec.name!r} (a-z, 0-9, дефис)")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == spec.server_id).first()
if not server:
raise PocError(f"Proxmox сервер id={spec.server_id} не найден")
on_step("Подключение к Proxmox...", 5)
client = _get_proxmox_client(server)
if not client.is_connected():
raise PocError("Не удалось подключиться к Proxmox")
node = _resolve_node(client, spec.node)
vmid = spec.vmid or get_next_vmid(db, server.id)
# Идемпотентность повтора (ТЗ 6.1): если контейнер с этим vmid уже создан —
# не пересоздаём, продолжаем с доставки/запуска compose.
already_exists = False
if spec.vmid:
try:
already_exists = bool(client.get_container_status(node, vmid))
except Exception:
already_exists = False
if already_exists:
on_step(f"Контейнер VMID {vmid} уже существует — повтор без пересоздания", 40)
else:
on_step(f"Создание LXC VMID {vmid} на ноде {node}...", 20)
ip_cfg = spec.ip_config or "ip=dhcp"
net0 = f"name=eth0,bridge={spec.bridge},{ip_cfg},firewall=1"
upid = client.create_lxc_container(
node=node,
vmid=vmid,
ostemplate=spec.ostemplate,
hostname=spec.name,
cores=spec.cores,
memory=spec.memory,
swap=spec.swap,
storage=spec.storage,
rootfs_size=spec.disk,
net0=net0,
features=spec.features, # nesting=1,keyctl=1 — критично для Docker
unprivileged=True,
start_after_create=False,
description=f"pvemanager-appstore: {spec.name}",
nameserver=spec.nameserver or settings.APPSTORE_NAMESERVER,
)
if not upid:
raise PocError("Proxmox не вернул UPID создания контейнера")
on_step("Ожидание завершения создания...", 35)
if not client.wait_for_task(node, upid, timeout=300):
raise PocError("Таймаут создания контейнера (>5 мин)")
on_step("Запуск контейнера...", 45)
client.start_container(node, vmid)
ssh = _node_ssh(server, client)
if not ssh.connect():
raise PocError("SSH к ноде не установлен (нужен root-доступ к ноде для pct exec/push)")
try:
on_step("Ожидание IP-адреса...", 55)
# Статический IP из IPAM известен заранее — подтверждаем через контейнер,
# но при таймауте всё равно используем выделенный адрес.
ip = _wait_ip(ssh, vmid, spec.ip_timeout) or spec.static_ip
if not ip:
raise PocError(f"Контейнер не получил IP за {spec.ip_timeout}с")
on_step("Подготовка каталогов и Docker-сети...", 65)
_run(ssh, _pct(vmid, f"mkdir -p {APP_DIR} {APP_DATA_DIR}"), what="mkdir app dirs")
_run(
ssh,
_pct(vmid, f"docker network inspect {TIPI_NETWORK} >/dev/null 2>&1 || "
f"docker network create {TIPI_NETWORK}"),
what="ensure docker network",
)
on_step("Доставка docker-compose.yml и .env (pct push)...", 75)
normalized, pub_port = _normalize_compose(spec.compose_yaml, spec.port,
source=spec.source,
port_explicit=spec.port_explicit)
if pub_port:
# Порт, реально опубликованный на хосте LXC → на нём и проверяем/URL.
spec.port = pub_port
_push_file(ssh, vmid, normalized, f"{APP_DIR}/docker-compose.yml")
# IP-зависимые платформенные переменные (ТЗ 5.3) заполняем здесь — IP уже известен.
env = _platform_env(dict(spec.env_vars), ip, spec.source)
_push_file(ssh, vmid, _env_file_content(env), f"{APP_DIR}/.env")
# Файлы приложения из репозитория каталога (конфиги/скрипты) — ДО
# подготовки bind-mount'ов, чтобы на их местах не появились пустышки.
_push_data_files(ssh, vmid, spec.data_files, env)
# Bind-mount директории заранее и с правами 777 — иначе Docker создаст
# их сам от root при первом `up`, и процессы в контейнере (напр.
# mariadb от uid mysql) получат Permission denied на запись.
_prepare_bind_mounts(ssh, vmid, normalized, APP_DIR, env)
on_step("Запуск docker compose up -d...", 85)
_run(
ssh,
_pct(vmid, f"cd {APP_DIR} && docker compose --env-file .env up -d 2>&1"),
what="docker compose up",
timeout=_DOCKER_TIMEOUT, # тянет образы — может идти минуты
)
on_step(f"Health-check http://{ip}:{spec.port} ...", 92)
ok = _health_check(ip, spec.port, spec.health_timeout)
status = "running" if ok else "unhealthy"
url = f"http://{ip}:{spec.port}"
on_step(f"Готово ({status}): {url}", 100)
return InstallResult(vmid=vmid, node=node, ip=ip, url=url, status=status)
finally:
ssh.close()
def _health_check(ip: str, port: int, timeout: int) -> bool:
"""Успех = любой HTTP-ответ (включая 3xx/401). Ретраи каждые 5с до timeout."""
import httpx
deadline = time.time() + timeout
url = f"http://{ip}:{port}"
while time.time() < deadline:
try:
r = httpx.get(url, timeout=5, follow_redirects=False)
logger.info(f"[appstore-poc] health {url} -> HTTP {r.status_code}")
return True
except Exception:
time.sleep(5)
return False
def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
env_vars: Dict[str, str], port: int, ip: str,
on_step: StepCb = _noop, health_timeout: int = 300,
source: str = "runtipi",
data_files: Optional[Dict[str, dict]] = None,
port_explicit: bool = False) -> tuple[str, int]:
"""Обновление приложения в существующем контейнере (M4): доставка нового
docker-compose.yml → docker compose pull && up -d → health-check.
Возвращает ('running' | 'unhealthy', эффективный_порт). Порт возвращаем,
потому что нормализация compose может опубликовать порт, отличный от
сохранённого в БД (ia.port), — иначе URL/health в БД укажут не туда.
"""
ssh = _node_ssh(server, client)
if not ssh.connect():
raise PocError("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")
_run(
ssh,
_pct(vmid, f"docker network inspect {TIPI_NETWORK} >/dev/null 2>&1 || "
f"docker network create {TIPI_NETWORK}"),
what="ensure docker network",
)
normalized, pub_port = _normalize_compose(compose_yaml, port, source=source,
port_explicit=port_explicit)
if pub_port:
port = pub_port
_push_file(ssh, vmid, normalized, f"{APP_DIR}/docker-compose.yml")
env = _platform_env(dict(env_vars), ip, source)
_push_file(ssh, vmid, _env_file_content(env), f"{APP_DIR}/.env")
_push_data_files(ssh, vmid, data_files or {}, env)
_prepare_bind_mounts(ssh, vmid, normalized, APP_DIR, env)
on_step("docker compose pull && up -d...", 65)
_run(
ssh,
_pct(vmid, f"cd {APP_DIR} && docker compose --env-file .env pull 2>&1 && "
f"docker compose --env-file .env up -d 2>&1"),
what="docker compose pull/up",
timeout=_DOCKER_TIMEOUT, # pull больших образов — может идти минуты
)
on_step(f"Health-check http://{ip}:{port} ...", 88)
return ("running" if _health_check(ip, port, health_timeout) else "unhealthy"), port
finally:
ssh.close()
def poc_teardown(db: Session, server_id: int, vmid: int, node: Optional[str] = None,
on_step: StepCb = _noop) -> None:
"""Остановить и удалить контейнер PoC (для повторяемого прогона)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise PocError(f"Proxmox сервер id={server_id} не найден")
client = _get_proxmox_client(server)
node = node or _resolve_node(client, None)
on_step(f"Остановка LXC {vmid}...", 30)
try:
upid = client.stop_container(node, vmid, force=True)
if upid:
client.wait_for_task(node, upid, timeout=120)
except Exception as e:
logger.warning(f"stop_container: {e}")
on_step(f"Удаление LXC {vmid}...", 70)
try:
upid = client.delete_container(node, vmid, force=True)
except Exception as e:
# CT уже отсутствует (удалён вручную в Proxmox / статус orphaned) —
# считаем teardown успешным, иначе запись в БД нельзя удалить никогда:
# каждый повтор удаления падал бы на этом же месте.
msg = str(e).lower()
if "does not exist" in msg or "no such" in msg:
on_step(f"LXC {vmid} уже отсутствует в Proxmox — пропускаем", 100)
return
raise
if upid:
# Ждём реального завершения задачи, а не просто отдаём таймаут наверх:
# иначе caller решит, что ресурсы освобождены, пока нода ещё занята
# (напр. wipe диска) — и следующая установка с тем же VMID словит
# "can't lock file ... got timeout" (см. инцидент с VMID 102).
done = client.wait_for_task(node, upid, timeout=_TEARDOWN_WAIT_TIMEOUT)
if not done:
raise PocError(
f"Удаление LXC {vmid}: Proxmox не подтвердил завершение задачи "
f"за {_TEARDOWN_WAIT_TIMEOUT}с (нода может ещё выполнять wipe "
f"диска) — ресурсы не считаются освобождёнными, повторите позже"
)
on_step("Удалено", 100)

View File

@@ -0,0 +1,9 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
ports:
- "${APP_PORT:-3001}:3001"
volumes:
- /opt/app-data/uptime-kuma:/app/data

View File

@@ -0,0 +1,7 @@
# Sample .env для PoC. Платформенные переменные App Store (маппинг Runtipi → PVEmanager).
APP_PORT=3001
APP_DATA_DIR=/opt/app-data
APP_PROTOCOL=http
APP_EXPOSED=false
TZ=Europe/Moscow
APP_ID=uptime-kuma

View File

@@ -1,9 +1,9 @@
"""
Встроенный curated-каталог cloud-образов ВМ (qcow2).
URL-ы ведут на «latest»/«current» зеркала дистрибутивов — образ всегда свежий
актуального point-релиза. checksum опционален (фиксированную сумму к плавающему
latest-URL прибить нельзя), поэтому проверка включается только если она задана.
amd64-образы берутся с собственного GitHub-зеркала (markmorado/pvemanager-images)
с предустановленным qemu-guest-agent и фиксированным sha256. arm64-образы идут
напрямую с upstream-серверов дистрибутивов (патченых arm64-версий нет).
LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их список берётся динамически из
репозитория Proxmox (aplinfo), плюс произвольные vztmpl-зеркала из ImageMirror.
@@ -12,50 +12,59 @@ LXC-шаблоны (vztmpl) здесь НЕ хардкодятся: их спи
гость для ARM-ноды; UI фильтрует по arch выбранной ноды.
"""
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
# (id_base, os, version, name, icon, {arch: url})
_GH = 'https://github.com/markmorado/pvemanager-images/releases/download/2026-08-14'
# (id_base, os, version, name, icon, {arch: (url, checksum_or_None)})
# amd64 → собственное GitHub-зеркало + sha256; arm64 → upstream без checksum
_CLOUD_IMAGES = [
(
'ubuntu-22.04-cloud', 'ubuntu', '22.04', 'Ubuntu 22.04 LTS (Jammy) Cloud', 'ubuntu',
{
'amd64': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img',
'arm64': 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img',
'amd64': (f'{_GH}/jammy-server-cloudimg-amd64.img',
'a5aa63f38e689bf043cfb2789a86057ee47131d21c21b611f05639313df2f3d9'),
'arm64': ('https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img', None),
},
),
(
'ubuntu-24.04-cloud', 'ubuntu', '24.04', 'Ubuntu 24.04 LTS (Noble) Cloud', 'ubuntu',
{
'amd64': 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img',
'arm64': 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img',
'amd64': (f'{_GH}/noble-server-cloudimg-amd64.img',
'd2490b42be04a1a69ad4809aff8bf1e9385d506f482468d494dc2a3916f843fb'),
'arm64': ('https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img', None),
},
),
(
'debian-11-cloud', 'debian', '11', 'Debian 11 (Bullseye) GenericCloud', 'debian',
{
'amd64': 'https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2',
'arm64': 'https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2',
'amd64': (f'{_GH}/debian-11-genericcloud-amd64.qcow2',
'be2d0545e37252a4296323264196156acaba37602ad4a7817a9cd95a300a2632'),
'arm64': ('https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2', None),
},
),
(
'debian-12-cloud', 'debian', '12', 'Debian 12 (Bookworm) GenericCloud', 'debian',
{
'amd64': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2',
'arm64': 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2',
'amd64': (f'{_GH}/debian-12-genericcloud-amd64.qcow2',
'0a3ca928e28b2544df03afb057b5512762b22e325b7640845bade651903dd490'),
'arm64': ('https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2', None),
},
),
(
'rocky-9-cloud', 'rocky', '9', 'Rocky Linux 9 GenericCloud', 'rocky',
{
'amd64': 'https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2',
'arm64': 'https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2',
'amd64': (f'{_GH}/Rocky-9-GenericCloud.latest.x86_64.qcow2',
'a01fbfa6750c2a7f1c290f7e2ab51bfdbb564e95413f8f52ab27175a7646e30b'),
'arm64': ('https://download.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2', None),
},
),
(
'alma-9-cloud', 'almalinux', '9', 'AlmaLinux 9 GenericCloud', 'almalinux',
{
'amd64': 'https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
'arm64': 'https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2',
'amd64': (f'{_GH}/AlmaLinux-9-GenericCloud-latest.x86_64.qcow2',
'49c75c0b49d9831338d06288e7f751f98302144218b462e5d9b2b6fe25c66f5e'),
'arm64': ('https://repo.almalinux.org/almalinux/9/cloud/aarch64/images/AlmaLinux-9-GenericCloud-latest.aarch64.qcow2', None),
},
),
]
@@ -69,7 +78,7 @@ def _filename_for(url: str) -> str:
def _build() -> List[Dict]:
items: List[Dict] = []
for id_base, os_name, version, name, icon, urls in _CLOUD_IMAGES:
for arch, url in urls.items():
for arch, (url, checksum) in urls.items():
items.append({
'id': f'{id_base}-{arch}',
'source': 'builtin',
@@ -80,8 +89,8 @@ def _build() -> List[Dict]:
'name': name,
'url': url,
'filename': _filename_for(url),
'checksum': None,
'checksum_algorithm': None,
'checksum': checksum,
'checksum_algorithm': 'sha256' if checksum else None,
'icon': icon,
})
return items

View File

@@ -72,6 +72,41 @@ class Settings(BaseSettings):
# Timezone
TZ: str = Field(default="UTC", env="TZ")
# ── App Store (модуль каталога self-hosted приложений) ──────────────────
# Источник каталога. Рекомендуется фиксировать REF на конкретный tag/commit
# (митигация риска смены формата Runtipi, ТЗ п.13); дефолт master — «всегда свежий».
RUNTIPI_APPSTORE_REPO: str = Field(default="runtipi/runtipi-appstore", env="RUNTIPI_APPSTORE_REPO")
RUNTIPI_APPSTORE_REF: str = Field(default="master", env="RUNTIPI_APPSTORE_REF")
# Дополнительный источник каталога — Umbrel (getumbrel/umbrel-apps). Формат
# манифеста иной (umbrel-app.yml + docker-compose.yml), поэтому подключается
# отдельным провайдером; см. UmbrelCatalogProvider.
UMBREL_APPSTORE_REPO: str = Field(default="getumbrel/umbrel-apps", env="UMBREL_APPSTORE_REPO")
UMBREL_APPSTORE_REF: str = Field(default="master", env="UMBREL_APPSTORE_REF")
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в галерее
# (<app-id>/icon.svg). Тянем их пофайлово с GitHub Pages CDN параллельно —
# это быстрее, чем качать тяжёлый tarball галереи (со скриншотами).
UMBREL_APPSTORE_GALLERY_CDN: str = Field(
default="https://getumbrel.github.io/umbrel-apps-gallery",
env="UMBREL_APPSTORE_GALLERY_CDN")
# Активные источники каталога (через запятую): "runtipi", "umbrel" или оба.
# По умолчанию только runtipi — Umbrel включается явно.
APPSTORE_SOURCES: str = Field(default="runtipi", env="APPSTORE_SOURCES")
# Каталог для кэша логотипов/данных App Store (монтируемый volume — переживает пересборку).
APPSTORE_DATA_DIR: str = Field(default="/app/data/appstore", env="APPSTORE_DATA_DIR")
# Периодичность авто-синхронизации каталога (ТЗ F-CAT-1).
CATALOG_SYNC_INTERVAL_HOURS: int = Field(default=24, env="CATALOG_SYNC_INTERVAL_HOURS")
# Архитектура целевых нод (для пометки неподдерживаемых приложений). amd64|arm64.
APPSTORE_HOST_ARCH: str = Field(default="amd64", env="APPSTORE_HOST_ARCH")
# Золотой LXC-шаблон (vztmpl) с предустановленным Docker — из него клонируются приложения.
# Напр. "local:vztmpl/golden-docker_12_amd64.tar.zst". Обязателен для установки (M2).
APPSTORE_GOLDEN_TEMPLATE: Optional[str] = Field(default=None, env="APPSTORE_GOLDEN_TEMPLATE")
# DNS для LXC, создаваемых App Store (через пробел — несколько серверов).
# Явно задаём публичный резолвер: без этого контейнер наследует DNS ноды,
# который может указывать на адрес, недоступный из сети контейнера
# (напр. Tailscale-резолвер хоста) — тогда `docker compose up` падает на
# pull образов с "i/o timeout" при резолве registry-1.docker.io.
APPSTORE_NAMESERVER: str = Field(default="1.1.1.1 8.8.8.8", env="APPSTORE_NAMESERVER")
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",

View File

@@ -186,26 +186,27 @@ class IPAMService:
# Also exclude gateway
gateway_ip = network.gateway if network.gateway else None
# Search through pools for available IP — query only per-pool allocated IPs
# Занятые IP собираем ГЛОБАЛЬНО (без фильтра по pool_id/статусу) —
# ровно так же проверяет allocate_ip (ip_address уникален на всю БД).
# Фильтр по pool_id пропускал аллокации с pool_id=NULL или из
# пересекающегося пула: сервис предлагал занятый IP, allocate_ip
# отвечал "already allocated", и создание инстанса срывалось,
# хотя свободные адреса были.
occupied = set(
row[0] for row in self.db.query(IPAMAllocation.ip_address).all()
)
if gateway_ip:
occupied.add(gateway_ip)
# Search through pools for available IP
for pool in pools:
try:
start_ip = ipaddress.ip_address(pool.range_start)
end_ip = ipaddress.ip_address(pool.range_end)
# Load only allocated IPs for this specific pool (much smaller set)
pool_allocated = set(
a.ip_address for a in
self.db.query(IPAMAllocation.ip_address).filter(
IPAMAllocation.pool_id == pool.id,
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
)
if gateway_ip:
pool_allocated.add(gateway_ip)
for ip_int in range(int(start_ip), int(end_ip) + 1):
ip = str(ipaddress.ip_address(ip_int))
if ip not in pool_allocated:
if ip not in occupied:
logger.info(f"Found available IP: {ip} in pool {pool.name}")
return ip
@@ -450,10 +451,14 @@ class IPAMService:
# First try to find by proxmox_server_id and proxmox_vmid
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
# Fallback: search by resource_id if proxmox fields are not set
# Fallback: search by resource_id if proxmox fields are not set.
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
# на сервере B (записи с заполненным server_id находит основной поиск).
if not allocation:
allocation = self.db.query(IPAMAllocation).filter(
IPAMAllocation.resource_id == proxmox_vmid,
IPAMAllocation.proxmox_server_id.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).first()
if allocation:

View File

@@ -24,6 +24,8 @@ from .api import notifications as notifications_router
from .api import users as users_router
from .api import workspaces as workspaces_router
from .api import ssh_keys as ssh_keys_router
from .api import appstore as appstore_router
from .api import scripts as scripts_router
from .logging_middleware import RequestLoggingMiddleware
from .language_middleware import LanguageMiddleware
from .i18n import I18nService
@@ -201,6 +203,22 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Metrics broadcaster startup failed: {e}")
# Start App Store catalog scheduler (periodic runtipi-appstore sync)
try:
from .services.appstore_catalog import start_catalog_scheduler
start_catalog_scheduler()
logger.info("App Store catalog scheduler started")
except Exception as e:
logger.warning(f"App Store catalog scheduler startup failed: {e}")
# Start App Store state reconciliation (LXC status vs DB, orphan detection)
try:
from .services.appstore_engine import start_reconcile_scheduler
start_reconcile_scheduler()
logger.info("App Store reconcile scheduler started")
except Exception as e:
logger.warning(f"App Store reconcile scheduler startup failed: {e}")
logger.info("Application startup complete")
yield
@@ -429,6 +447,8 @@ def create_app() -> FastAPI:
app.include_router(users_router.router, prefix="/admin", tags=["users", "admin"])
app.include_router(workspaces_router.router, tags=["workspaces"])
app.include_router(ssh_keys_router.router, tags=["ssh-keys"])
app.include_router(appstore_router.router, tags=["appstore"])
app.include_router(scripts_router.router, tags=["scripts"])
return app

View File

@@ -7,6 +7,8 @@ from .metrics import InstanceMetric, InstanceNicMetric
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
from .catalog import ImageMirror
from .appstore import CatalogApp, InstalledApp, AppOperation
from .scripts import ScriptCatalog, ScriptGitRepo, ScriptExecution
__all__ = [
'Base', 'utcnow',
@@ -17,5 +19,7 @@ __all__ = [
'InstanceMetric', 'InstanceNicMetric',
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog',
'ImageMirror'
'ImageMirror',
'CatalogApp', 'InstalledApp', 'AppOperation',
'ScriptCatalog', 'ScriptGitRepo', 'ScriptExecution',
]

View File

@@ -0,0 +1,254 @@
from .base import *
import json as _json
# Статусы установленного приложения (ТЗ 8)
INSTALLED_STATUSES = (
"installing", "running", "stopped", "error",
"updating", "update_failed", "orphaned", "deleting",
)
class CatalogApp(Base):
"""
Приложение из каталога App Store (импорт из runtipi/runtipi-appstore).
Наполняется Catalog Service (services/appstore_catalog.py). Публичные
метаданные — шифрование не требуется. Секреты появляются только на этапе
установки (installed_apps.env_encrypted, M2).
"""
__tablename__ = "catalog_apps"
# id приложения из runtipi (config.json → "id"), напр. "uptime-kuma".
# Для источников кроме runtipi app_id префиксуется ("umbrel-<id>"), чтобы
# не было коллизий PK между каталогами разных источников.
app_id = Column(String(100), primary_key=True, index=True)
name = Column(String(200), nullable=False)
# источник каталога: "runtipi" | "umbrel". Определяет адаптацию compose
# при установке (публикация порта, вырезание рантайм-сервисов источника).
source = Column(String(50), nullable=False, default="runtipi", index=True)
# version — строка тега docker-образа ("2", "1.23.1"); tipi_version — целочисленный
# инкремент версии рецепта (используется для update_available в M2).
version = Column(String(50), nullable=True)
tipi_version = Column(Integer, nullable=True)
categories = Column(JSON, nullable=False, default=list)
short_desc = Column(Text, nullable=True)
description_md = Column(Text, nullable=True)
# относительный путь логотипа внутри APPSTORE_DATA_DIR (напр. "logos/uptime-kuma.jpg")
logo_path = Column(String(300), nullable=True)
port = Column(Integer, nullable=True)
# массив объектов form_fields из config.json (см. ТЗ 6.2)
form_fields = Column(JSON, nullable=False, default=list)
compose_yaml = Column(Text, nullable=True)
# Вспомогательные файлы приложения из репозитория каталога, которые compose
# bind-mount'ит из ${APP_DATA_DIR} (runtipi: apps/<id>/data/**, umbrel: файлы
# папки приложения). Без них ~60% приложений получают пустые конфиги/скрипты
# и падают на старте. Формат: {relpath: {"b64": <содержимое>, "exec": bool}}.
data_files = Column(JSON, nullable=True)
# Учётные данные по умолчанию из манифеста источника (umbrel-app.yml:
# defaultUsername/defaultPassword/deterministicPassword). deterministic=true →
# реальный пароль — это сгенерированный при установке APP_PASSWORD.
# Формат: {"username": str|None, "password": str|None, "deterministic": bool}.
default_credentials = Column(JSON, nullable=True)
architectures = Column(JSON, nullable=False, default=list)
available = Column(Boolean, nullable=False, default=True, index=True)
unavailable_reason = Column(String(200), nullable=True)
deprecated = Column(Boolean, nullable=False, default=False)
# config.json → dynamic_config: приложение имеет docker-compose.json (новый формат).
# В MVP всё равно используем docker-compose.yml; флаг сохраняем на будущее (риск п.13 ТЗ).
dynamic_config = Column(Boolean, nullable=False, default=False)
source_url = Column(String(500), nullable=True)
author = Column(String(200), nullable=True)
synced_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
def to_dict(self, *, light: bool = False, with_compose: bool = False) -> dict:
data = {
"app_id": self.app_id,
"name": self.name,
"source": self.source or "runtipi",
"version": self.version,
"tipi_version": self.tipi_version,
"categories": self.categories or [],
"short_desc": self.short_desc,
"port": self.port,
"architectures": self.architectures or [],
"available": self.available,
"unavailable_reason": self.unavailable_reason,
"deprecated": self.deprecated,
"dynamic_config": self.dynamic_config,
"source_url": self.source_url,
"author": self.author,
"logo_url": f"/api/appstore/catalog/{self.app_id}/logo" if self.logo_path else None,
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
}
if not light:
# тяжёлые поля — только для страницы приложения
data["form_fields"] = self.form_fields or []
data["description_md"] = self.description_md
if with_compose:
data["compose_yaml"] = self.compose_yaml
return data
def __repr__(self):
return f"<CatalogApp(app_id='{self.app_id}', name='{self.name}', v='{self.version}')>"
class InstalledApp(Base):
"""
Установленное приложение = 1 LXC-контейнер с Docker Compose внутри (ТЗ 8).
env_encrypted — JSON-строка переменных .env (включая сгенерированные секреты),
хранится зашифрованной (Fernet) через EncryptedString.
"""
__tablename__ = "installed_apps"
id = Column(Integer, primary_key=True, index=True)
# ссылка на каталог (мягкая — приложение может исчезнуть из каталога)
app_id = Column(String(100), index=True, nullable=True)
server_id = Column(Integer, index=True, nullable=True)
vmid = Column(Integer, index=True, nullable=True)
node = Column(String(100), nullable=True)
name = Column(String(200), nullable=False) # пользовательское имя / hostname LXC
version_installed = Column(String(50), nullable=True)
tipi_version_installed = Column(Integer, nullable=True)
env_encrypted = Column(EncryptedString, nullable=True) # JSON строки .env (зашифровано)
ip = Column(String(64), nullable=True)
port = Column(Integer, nullable=True)
status = Column(String(20), nullable=False, default="installing", index=True)
update_available = Column(Boolean, nullable=False, default=False)
last_snapshot = Column(String(200), nullable=True)
# Параметры создания CT ({cores, memory, disk, storage, bridge, ip_config}) —
# чтобы повтор установки пересоздал контейнер с исходными настройками,
# а не с дефолтами (NULL у записей, созданных до появления колонки).
install_params = Column(JSON, nullable=True)
# версия/tipi_version, зафиксированные в last_snapshot — для отката версии в БД (M4)
snapshot_version = Column(String(50), nullable=True)
snapshot_tipi_version = Column(Integer, nullable=True)
owner_id = Column(Integer, index=True, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def env_dict(self) -> dict:
if not self.env_encrypted:
return {}
try:
return _json.loads(self.env_encrypted)
except Exception:
return {}
def to_dict(self) -> dict:
return {
"id": self.id,
"app_id": self.app_id,
"server_id": self.server_id,
"vmid": self.vmid,
"node": self.node,
"name": self.name,
"version_installed": self.version_installed,
"tipi_version_installed": self.tipi_version_installed,
"ip": self.ip,
"port": self.port,
"url": f"http://{self.ip}:{self.port}" if self.ip and self.port else None,
"status": self.status,
"update_available": self.update_available,
"last_snapshot": self.last_snapshot,
"owner_id": self.owner_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<InstalledApp(id={self.id}, name='{self.name}', vmid={self.vmid}, status='{self.status}')>"
# Человекочитаемые названия операций для общего списка «Задачи».
APP_OPERATION_LABELS = {
"install": "Установка приложения",
"update": "Обновление приложения",
"rollback": "Откат приложения",
"delete": "Удаление приложения",
"start": "Запуск приложения",
"stop": "Остановка приложения",
"restart": "Перезапуск приложения",
"golden_template": "Подготовка золотого шаблона",
}
class AppOperation(Base):
"""Журнал операции над приложением (install/update/rollback/delete/...) — ТЗ 8."""
__tablename__ = "app_operations"
id = Column(Integer, primary_key=True, index=True)
installed_app_id = Column(Integer, ForeignKey("installed_apps.id", ondelete="CASCADE"), index=True, nullable=True)
# Только для операций без installed_app_id (напр. golden_template) — чтобы
# различать прогресс/историю сборки шаблона по серверам Proxmox.
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), index=True, nullable=True)
type = Column(String(20), nullable=False) # install/update/rollback/delete/start/stop/restart
status = Column(String(20), nullable=False, default="running") # running/completed/failed
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)
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 {
"id": self.id,
"kind": "appstore",
"installed_app_id": self.installed_app_id,
"server_id": self.server_id,
"type": self.type,
"status": self.status,
"progress": self.progress,
"steps_log": self.steps_log or [],
"error_text": self.error_text,
"user_id": self.user_id,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
}
def to_task_dict(self, *, app_name: Optional[str] = None,
node: Optional[str] = None,
server_id: Optional[int] = None) -> dict:
"""Представление операции для общего списка «Задачи» (/api/all-tasks).
Поля приведены к общему виду с TaskQueue/ProxmoxTask/DeployTask:
description, step/current_step, error_message, created_at, progress.
"""
label = APP_OPERATION_LABELS.get(self.type, self.type)
steps = self.steps_log or []
last_step = steps[-1].get("step") if steps else None
return {
"id": self.id,
"kind": "appstore",
"type": self.type,
"description": f"{label}: {app_name}" if app_name else label,
"status": self.status,
"progress": self.progress,
"step": last_step,
"current_step": last_step,
"error_message": self.error_text,
"installed_app_id": self.installed_app_id,
"user_id": self.user_id,
"node": node,
"server_id": server_id,
"created_at": self.started_at.isoformat() if self.started_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
}
def __repr__(self):
return f"<AppOperation(id={self.id}, type='{self.type}', status='{self.status}')>"

View File

@@ -65,6 +65,8 @@ class User(Base):
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)
# List of SHA-256 hashes of single-use recovery codes (JSON array).
two_factor_backup_codes = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -19,7 +19,13 @@ class IPAMNetwork(Base):
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_node = Column(String(100), nullable=True)
proxmox_bridge = Column(String(20), nullable=True)
# Привязка сети к рабочей области (workspace). Одна общая подсеть на область:
# сеть/пул выдаются любому узлу области, без привязки к конкретной ноде.
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True, index=True)
# Сеть по умолчанию внутри области — автоматически выбирается в мастерах.
is_default = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -85,7 +85,10 @@ class ProxmoxServer(Base):
api_user = Column(String(100), nullable=False, default="root@pam")
api_token_name = Column(String(100), nullable=True)
api_token_value = Column(String(255), nullable=True)
# Encrypted like the password: with auto-provisioning the token becomes the
# primary credential. EncryptedString stores as String and falls back to
# plaintext on read, so existing rows keep working until they are rewritten.
api_token_value = Column(EncryptedString(255), nullable=True)
use_password = Column(Boolean, nullable=False, default=False)
password = Column(EncryptedString(255), nullable=True)
@@ -100,6 +103,9 @@ class ProxmoxServer(Base):
is_online = Column(Boolean, nullable=True, default=None)
last_error = Column(Text, nullable=True)
# Почему сервер недоступен: 'auth' — API ответил 401 (протух токен/пароль),
# 'connection' — не отвечает вовсе. Лечится по-разному, поэтому различаем.
last_error_kind = Column(String(20), nullable=True)
__table_args__ = (
Index('idx_proxmox_status', 'is_online', 'last_check'),
@@ -122,13 +128,15 @@ class ProxmoxServer(Base):
'last_check': self.last_check
}
def update_status(self, is_online: bool, error: str = None):
def update_status(self, is_online: bool, error: str = None, error_kind: str = None):
self.is_online = is_online
self.last_check = utcnow()
if error:
self.last_error = error
self.last_error_kind = error_kind or 'connection'
elif is_online:
self.last_error = None
self.last_error_kind = None
assigned_users = relationship("User", secondary="user_servers", back_populates="assigned_servers", lazy="select")

View File

@@ -0,0 +1,164 @@
from .base import *
# target_type скрипта: где он выполняется.
# "node" — на хосте Proxmox (root@node по SSH)
# "guest" — внутри VM (QEMU guest agent) или LXC (pct exec через SSH ноды)
SCRIPT_TARGET_TYPES = ("node", "guest")
SCRIPT_SOURCES = ("manual", "git", "community-scripts")
SCRIPT_EXECUTION_STATUSES = ("running", "success", "failed")
# Формат метаданных, по которому синкается git-источник:
# "header-comment" — заголовочные bash-комментарии (# name: / # param: ...)
# "community-scripts-ct" — ct/*.sh из community-scripts/ProxmoxVE (APP=, var_*)
SCRIPT_REPO_METADATA_FORMATS = ("header-comment", "community-scripts-ct")
class ScriptGitRepo(Base):
"""Git-источник каталога скриптов (аналог CatalogProvider для appstore)."""
__tablename__ = "script_git_repos"
id = Column(Integer, primary_key=True, index=True)
url = Column(String(500), nullable=False)
branch = Column(String(100), nullable=False, default="main")
# glob относительно корня репозитория, напр. "scripts/**/*.sh"
path_glob = Column(String(300), nullable=False, default="**/*.sh")
# см. SCRIPT_REPO_METADATA_FORMATS
metadata_format = Column(String(30), nullable=False, default="header-comment")
enabled = Column(Boolean, nullable=False, default=True)
last_synced_at = Column(DateTime(timezone=True), nullable=True)
last_sync_error = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"url": self.url,
"branch": self.branch,
"path_glob": self.path_glob,
"metadata_format": self.metadata_format,
"enabled": self.enabled,
"last_synced_at": self.last_synced_at.isoformat() if self.last_synced_at else None,
"last_sync_error": self.last_sync_error,
}
def __repr__(self):
return f"<ScriptGitRepo(id={self.id}, url='{self.url}')>"
class ScriptCatalog(Base):
"""Скрипт каталога — свой (manual) или синхронизированный из git."""
__tablename__ = "script_catalog"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
slug = Column(String(200), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
category = Column(String(100), nullable=True, index=True)
target_type = Column(String(20), nullable=False, default="guest") # node | guest
interpreter = Column(String(100), nullable=False, default="/bin/bash")
content = Column(Text, nullable=False)
# [{name, label, type: string|number|bool, required, default}]
params_schema = Column(JSON, nullable=False, default=list)
source = Column(String(20), nullable=False, default="manual", index=True) # manual | git
git_repo_id = Column(Integer, ForeignKey("script_git_repos.id", ondelete="CASCADE"), nullable=True, index=True)
git_path = Column(String(500), nullable=True)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def to_dict(self, *, with_content: bool = False) -> dict:
data = {
"id": self.id,
"name": self.name,
"slug": self.slug,
"description": self.description,
"category": self.category,
"target_type": self.target_type,
"interpreter": self.interpreter,
"params_schema": self.params_schema or [],
"source": self.source,
"git_repo_id": self.git_repo_id,
"git_path": self.git_path,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if with_content:
data["content"] = self.content
return data
def __repr__(self):
return f"<ScriptCatalog(id={self.id}, slug='{self.slug}', target_type='{self.target_type}')>"
SCRIPT_EXECUTION_OUTPUT_LIMIT = 200_000 # символов, защита от раздувания БД
class ScriptExecution(Base):
"""Журнал запуска скрипта (аналог AppOperation)."""
__tablename__ = "script_executions"
id = Column(Integer, primary_key=True, index=True)
script_id = Column(Integer, ForeignKey("script_catalog.id", ondelete="SET NULL"), nullable=True, index=True)
script_name = Column(String(200), nullable=True) # снимок имени на момент запуска
target_type = Column(String(20), nullable=False) # node | guest
server_id = Column(Integer, nullable=True, index=True)
node = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=True) # None для target_type="node"
params_used = Column(JSON, nullable=False, default=dict)
status = Column(String(20), nullable=False, default="running", index=True)
exit_code = Column(Integer, nullable=True)
output = Column(Text, nullable=True)
error_text = Column(Text, nullable=True)
user_id = Column(Integer, nullable=False, index=True)
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 {
"id": self.id,
"kind": "script",
"script_id": self.script_id,
"script_name": self.script_name,
"target_type": self.target_type,
"server_id": self.server_id,
"node": self.node,
"vmid": self.vmid,
"params_used": self.params_used or {},
"status": self.status,
"exit_code": self.exit_code,
"output": self.output,
"error_text": self.error_text,
"user_id": self.user_id,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
}
def to_task_dict(self) -> dict:
"""Представление для общего списка «Задачи» (/api/all-tasks)."""
return {
"id": self.id,
"kind": "script",
"type": "script_execution",
"description": f"Скрипт: {self.script_name or self.script_id}",
"status": self.status,
"progress": 100 if self.status != "running" else 50,
"step": None,
"current_step": None,
"error_message": self.error_text,
"user_id": self.user_id,
"node": self.node,
"server_id": self.server_id,
"created_at": self.started_at.isoformat() if self.started_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.finished_at.isoformat() if self.finished_at else None,
}
def __repr__(self):
return f"<ScriptExecution(id={self.id}, script_id={self.script_id}, status='{self.status}')>"

View File

@@ -121,6 +121,7 @@ class DeployTask(Base):
name = Column(String(100), nullable=False)
template_id = Column(Integer, nullable=True)
server_id = Column(Integer, nullable=True)
target_server_id = Column(Integer, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
vmid = Column(Integer, nullable=True)
@@ -140,11 +141,13 @@ class DeployTask(Base):
kind = self.kind or 'deploy'
descriptions = {
'deploy': f"Создание {self.name}",
'vm_iso': f"Создание ВМ под ISO: {self.name}",
'reinstall': f"Переустановка {self.name}",
'clone': f"Клонирование {self.name}",
'change_password': f"Смена пароля: {self.name}",
'image_download': f"Загрузка образа: {self.name}",
'image_template': f"Шаблон из образа: {self.name}",
'remote_migrate': f"Remote-миграция: {self.name}",
}
return {
'id': self.id,
@@ -158,6 +161,7 @@ class DeployTask(Base):
'name': self.name,
'template_id': self.template_id,
'server_id': self.server_id,
'target_server_id': self.target_server_id,
'vmid': self.vmid,
'node': self.node,
'error_message': self.error_message,

View File

@@ -140,6 +140,10 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
self.connection_key = f"{host}:{user}:{token_name or 'password'}"
self.proxmox = None
self.last_used = time.time()
# Причина неудачного подключения: 'auth' | 'connection' | None.
# Нужна, чтобы отличить протухший токен от недоступной ноды.
self.connect_error_kind = None
self.connect_error = None
# Check cache first
with connection_cache_lock:
@@ -198,6 +202,13 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.debug(f"Cached connection for {self.connection_key}")
except Exception as e:
logger.error(f"Failed to test Proxmox connection for {host}: {e}")
# При токен-авторизации 401 прилетает не AuthenticationError,
# а ResourceException со status_code — proxmoxer не делает
# отдельный логин, токен проверяется на первом же запросе.
self.connect_error_kind = (
'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
)
self.connect_error = str(e)
self.proxmox = None
# Удаляем неудачное подключение из кеша
with connection_cache_lock:
@@ -206,6 +217,8 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
except AuthenticationError as e:
logger.error(f"Ошибка аутентификации Proxmox {host}: {e}")
self.connect_error_kind = 'auth'
self.connect_error = str(e)
self.proxmox = None
# Удаляем из кеша при ошибке аутентификации
with connection_cache_lock:
@@ -213,6 +226,10 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
del connection_cache[self.connection_key]
except Exception as e:
logger.error(f"Ошибка подключения к Proxmox {host}: {e}")
self.connect_error_kind = (
'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
)
self.connect_error = str(e)
self.proxmox = None
# Удаляем из кеша при ошибке подключения
if self.connection_key in connection_cache:
@@ -229,6 +246,30 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
except Exception:
return False
def check_connection(self) -> tuple:
"""
Проверить API, отличая отказ авторизации от недоступности ноды.
«Нода не отвечает» и «нода ответила 401» чинятся по-разному: первое —
сеть/питание, второе — протухший токен (например, после ввода ноды в
кластер, когда user.cfg и priv/token.cfg замещаются кластерными).
Returns:
(ok, error_kind, error) — error_kind: None | 'auth' | 'connection'
"""
if not self.proxmox:
return (False, self.connect_error_kind or 'connection',
self.connect_error or 'Failed to connect')
try:
self.proxmox.version.get()
self.last_used = time.time()
return (True, None, None)
except AuthenticationError as e:
return (False, 'auth', str(e))
except Exception as e:
kind = 'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
return (False, kind, str(e))
@asynccontextmanager
async def ensure_connection(self):
"""Ensure connection is active before use"""
@@ -1187,14 +1228,24 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
except Exception as e:
return str(e)
def get_task_status(self, node: str, upid: str) -> Dict:
"""Get status of a Proxmox task by UPID"""
def get_task_status(self, node: str, upid: str, raise_on_error: bool = False) -> Dict:
"""Get status of a Proxmox task by UPID.
raise_on_error=True — пробросить исключение вместо возврата {}.
Нужно вызывающим, которым важно отличать «задача не найдена» от
транзитной сетевой ошибки (см. run_upid_task_sync): пустой словарь
эту разницу стирает.
"""
if not self.proxmox:
if raise_on_error:
raise RuntimeError(f"Proxmox client for {self.host} is not connected")
return {}
try:
status = self.proxmox.nodes(node).tasks(upid).status.get()
return status
except Exception as e:
if raise_on_error:
raise
logger.error(f"Error getting task status {upid}: {e}")
return {}
@@ -2015,13 +2066,31 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
if nameserver:
params['nameserver'] = nameserver
if searchdomain:
params['searchdomain'] = searchdomain
result = self.proxmox.nodes(node).lxc.post(**params)
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
return result
# Ретрай на транзиентную гонку блокировки конфига (частый случай:
# тот же VMID пересоздаётся сразу после удаления, пока нода ещё
# держит pve-config-<vmid>.lock — напр. фоновая очистка диска).
import time as _time
attempts = 3
for attempt in range(1, attempts + 1):
try:
result = self.proxmox.nodes(node).lxc.post(**params)
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
return result
except Exception as e:
msg = str(e)
is_lock_race = "got lock" in msg or "can't lock file" in msg
if is_lock_race and attempt < attempts:
logger.warning(
f"Создание LXC {vmid}: блокировка конфига (попытка "
f"{attempt}/{attempts}), повтор через 5с: {msg}"
)
_time.sleep(5)
continue
raise
except Exception as e:
logger.error(f"Ошибка создания LXC контейнера {vmid}: {e}")
return None
@@ -2159,6 +2228,111 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.error(f"Ошибка миграции LXC {vmid}: {e}")
raise RuntimeError(f"Proxmox: {e}") from e
def remote_migrate_vm(
self,
node: str,
vmid: int,
target_endpoint: str,
target_vmid: int,
target_storage: str = None,
target_bridge: str = None,
online: bool = False,
delete_source: bool = True,
bwlimit: int = None,
) -> Optional[str]:
"""
Мигрировать VM (qemu) на другой (независимый) кластер через remote_migrate
API (PVE >= 8.0). В отличие от migrate_vm, авторизация на целевой стороне
идёт по API-токену из target_endpoint, а не по членству в corosync.
Args:
node: Текущая нода VM
vmid: ID виртуальной машины на источнике
target_endpoint: Строка `apitoken=...,host=...,fingerprint=...` целевого кластера
target_vmid: ID виртуальной машины на цели
target_storage: Целевое хранилище (применяется ко всем дискам)
target_bridge: Целевой сетевой мост (применяется ко всем интерфейсам)
online: Live-миграция запущенной VM
delete_source: Удалить VM с источника после успешного переноса
bwlimit: Ограничение полосы (KiB/s)
Returns:
UPID задачи (на источнике) или None
"""
if not self.proxmox:
return None
try:
params = {
'target-endpoint': target_endpoint,
'target-vmid': target_vmid,
'delete': 1 if delete_source else 0,
}
if target_storage:
params['target-storage'] = target_storage
if target_bridge:
params['target-bridge'] = target_bridge
if online:
params['online'] = 1
if bwlimit:
params['bwlimit'] = bwlimit
result = self.proxmox.nodes(node).qemu(vmid).remote_migrate.post(**params)
logger.info(f"Remote-миграция VM {vmid} с {node} на {target_vmid}@{target_endpoint.split(',')[1] if ',' in target_endpoint else target_endpoint}")
return result if isinstance(result, str) else None
except Exception as e:
logger.error(f"Ошибка remote-миграции VM {vmid}: {e}")
raise RuntimeError(f"Proxmox: {e}") from e
def remote_migrate_container(
self,
node: str,
vmid: int,
target_endpoint: str,
target_vmid: int,
target_storage: str = None,
target_bridge: str = None,
online: bool = False,
delete_source: bool = True,
bwlimit: int = None,
restart: bool = False,
) -> Optional[str]:
"""
Мигрировать LXC контейнер на другой (независимый) кластер через
remote_migrate API (PVE >= 8.0). См. remote_migrate_vm.
Args:
restart: Перезапустить контейнер на источнике перед переносом
(аналог pct remote-migrate --restart, нужен, если online=False,
но контейнер запущен)
"""
if not self.proxmox:
return None
try:
params = {
'target-endpoint': target_endpoint,
'target-vmid': target_vmid,
'delete': 1 if delete_source else 0,
}
if target_storage:
params['target-storage'] = target_storage
if target_bridge:
params['target-bridge'] = target_bridge
if online:
params['online'] = 1
if restart:
params['restart'] = 1
if bwlimit:
params['bwlimit'] = bwlimit
result = self.proxmox.nodes(node).lxc(vmid).remote_migrate.post(**params)
logger.info(f"Remote-миграция LXC {vmid} с {node} на {target_vmid}")
return result if isinstance(result, str) else None
except Exception as e:
logger.error(f"Ошибка remote-миграции LXC {vmid}: {e}")
raise RuntimeError(f"Proxmox: {e}") from e
def get_vm_termproxy(self, node: str, vmid: int) -> Dict:
"""
Получить данные для терминального подключения к VM через xterm.js
@@ -2726,12 +2900,16 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
return {"success": False, "error": "Not connected"}
try:
params = {
"vmid": vmid,
"storage": storage,
"mode": mode,
"compress": compress,
"remove": int(bool(remove)),
}
# «Все VM»: vzdump требует all=1, а не vmid (vmid=0 — ошибка PVE)
if vmid in (None, 0, "all"):
params["all"] = 1
else:
params["vmid"] = vmid
if keep_last and int(keep_last) > 0:
params["prune-backups"] = f"keep-last={int(keep_last)}"
if notes:

View File

@@ -18,11 +18,30 @@ class DisksMixin:
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).disks.list.get() or [])
disks = list(self.proxmox.nodes(node).disks.list.get() or [])
for d in disks:
d["passthrough_path"] = self._disk_passthrough_path(d)
return disks
except Exception as e:
logger.error(f"Error getting disks for node {node}: {e}")
return []
@staticmethod
def _disk_passthrough_path(disk: Dict) -> Optional[str]:
"""Стабильный путь устройства для проброса в VM.
Предпочитаем /dev/disk/by-id/* (не меняется между загрузками),
затем wwn, в крайнем случае — devpath (/dev/sdX, нестабилен).
"""
by_id = disk.get("by_id_link") or disk.get("by-id-link")
if by_id:
return by_id if str(by_id).startswith("/dev/") else f"/dev/disk/by-id/{by_id}"
wwn = disk.get("wwn")
if wwn:
wwn = str(wwn).replace("0x", "")
return f"/dev/disk/by-id/wwn-0x{wwn}"
return disk.get("devpath")
def get_disk_smart(self, node: str, disk: str) -> Dict:
"""SMART-данные диска (health + attributes)."""
if not self.proxmox:

View File

@@ -93,6 +93,10 @@ class LxcMixin:
"""
Сменить пароль пользователя в LXC через `pct exec` (chpasswd).
Контейнер должен быть запущен.
REST-эндпоинта /nodes/{node}/lxc/{vmid}/exec в Proxmox нет (501),
поэтому выполняем pct exec по SSH (root@node) — тот же паттерн,
что в appstore/pipeline.py и _delete_cluster_node_via_ssh.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
@@ -100,16 +104,34 @@ class LxcMixin:
result = self.proxmox.nodes(node).lxc(vmid).status.current.get()
if result.get('status') != 'running':
return {'success': False, 'error': 'Container is not running'}
# Используем printf + chpasswd; shlex.quote защищает от shell-injection
# Proxmox exec API ожидает отдельные поля: command + args[N]
except Exception as e:
logger.error(f"Не удалось получить статус LXC {vmid}: {e}")
return {'success': False, 'error': str(e)}
from app.ssh_client import SSHClient
# self.host может содержать порт API ("1.2.3.4:8006"); SSH нужен голый хост.
ssh_host = self.host.split(':')[0] if self.host else self.host
ssh = SSHClient(
hostname=ssh_host,
username='root',
password=getattr(self, '_password', None),
)
try:
if not ssh.connect():
return {'success': False, 'error': 'SSH connection to node failed'}
# printf + chpasswd; shlex.quote защищает от shell-injection
entry = shlex.quote(f'{username}:{password}')
cmd_str = f"printf '%s\\n' {entry} | chpasswd"
data = {'command': 'bash', 'args[0]': '-c', 'args[1]': cmd_str}
res = self.proxmox.nodes(node).lxc(vmid).exec.post(**data)
return {'success': True, 'result': res}
inner = f"printf '%s\\n' {entry} | chpasswd"
cmd = f"pct exec {vmid} -- bash -c {shlex.quote(inner)}"
output, exit_code = ssh.execute(cmd, return_exit_code=True)
if exit_code == 0:
return {'success': True}
return {'success': False, 'error': output or f'chpasswd failed (exit {exit_code})'}
except Exception as e:
logger.error(f"Не удалось сменить пароль в LXC {vmid}: {e}")
return {'success': False, 'error': str(e)}
finally:
ssh.close()
def set_container_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у LXC."""

View File

@@ -4,10 +4,168 @@ from loguru import logger
class NodeAdminMixin:
"""
Администрирование ноды: systemd-сервисы (/nodes/{node}/services)
и APT (обновления и репозитории, /nodes/{node}/apt).
Администрирование ноды: питание (reboot/shutdown), systemd-сервисы
(/nodes/{node}/services), APT (обновления и репозитории, /nodes/{node}/apt),
журналы (syslog), история задач и системная конфигурация (DNS/hosts/time).
"""
# ---------- Power (reboot / shutdown) ----------
def node_power_action(self, node: str, command: str) -> Dict:
"""
Управление питанием ноды: reboot | shutdown.
Проксирует POST /nodes/{node}/status?command=... как в UI Proxmox.
"""
if command not in ("reboot", "shutdown"):
return {"success": False, "error": "command must be reboot or shutdown"}
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).status.post(command=command)
return {"success": True, "command": command}
except Exception as e:
logger.error(f"Error {command} node {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- Syslog ----------
def get_node_syslog(self, node: str, limit: int = 500, start: int = 0,
service: str = None, since: str = None, until: str = None) -> List[Dict]:
"""Системный журнал ноды: [{n, t}] (номер строки и текст)."""
if not self.proxmox:
return []
params = {"limit": limit, "start": start}
if service:
params["service"] = service
if since:
params["since"] = since
if until:
params["until"] = until
try:
return list(self.proxmox.nodes(node).syslog.get(**params) or [])
except Exception as e:
logger.error(f"Error getting syslog for {node}: {e}")
return []
# ---------- Task history ----------
def get_node_tasks(self, node: str, limit: int = 100, start: int = 0,
errors: bool = False, typefilter: str = None,
userfilter: str = None) -> List[Dict]:
"""История задач ноды: [{upid, type, id, user, status, starttime, endtime}]."""
if not self.proxmox:
return []
params = {"limit": limit, "start": start}
if errors:
params["errors"] = 1
if typefilter:
params["typefilter"] = typefilter
if userfilter:
params["userfilter"] = userfilter
try:
return list(self.proxmox.nodes(node).tasks.get(**params) or [])
except Exception as e:
logger.error(f"Error getting tasks for {node}: {e}")
return []
def get_node_task_log(self, node: str, upid: str, limit: int = 500, start: int = 0) -> List[Dict]:
"""Лог конкретной задачи: [{n, t}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).tasks(upid).log.get(limit=limit, start=start) or [])
except Exception as e:
logger.error(f"Error getting task log {upid} on {node}: {e}")
return []
def get_node_task_status(self, node: str, upid: str) -> Dict:
"""Статус конкретной задачи: {status, exitstatus, type, user, ...}."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).tasks(upid).status.get() or {})
except Exception as e:
logger.error(f"Error getting task status {upid} on {node}: {e}")
return {}
# ---------- System config: DNS / hosts / time ----------
def get_node_dns(self, node: str) -> Dict:
"""DNS ноды: {search, dns1, dns2, dns3}."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).dns.get() or {})
except Exception as e:
logger.error(f"Error getting DNS for {node}: {e}")
return {}
def set_node_dns(self, node: str, search: str = None, dns1: str = None,
dns2: str = None, dns3: str = None) -> Dict:
"""Настроить DNS ноды (search обязателен для Proxmox)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
params = {}
if search is not None:
params["search"] = search
if dns1 is not None:
params["dns1"] = dns1
if dns2 is not None:
params["dns2"] = dns2
if dns3 is not None:
params["dns3"] = dns3
try:
self.proxmox.nodes(node).dns.put(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error setting DNS on {node}: {e}")
return {"success": False, "error": str(e)}
def get_node_hosts(self, node: str) -> Dict:
"""Содержимое /etc/hosts ноды: {data, digest}."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).hosts.get() or {})
except Exception as e:
logger.error(f"Error getting hosts for {node}: {e}")
return {}
def set_node_hosts(self, node: str, data: str, digest: str = None) -> Dict:
"""Записать /etc/hosts ноды (digest — защита от гонок, опционально)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
params = {"data": data}
if digest:
params["digest"] = digest
try:
self.proxmox.nodes(node).hosts.post(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error setting hosts on {node}: {e}")
return {"success": False, "error": str(e)}
def get_node_time(self, node: str) -> Dict:
"""Время ноды: {timezone, time, localtime}."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).time.get() or {})
except Exception as e:
logger.error(f"Error getting time for {node}: {e}")
return {}
def set_node_timezone(self, node: str, timezone: str) -> Dict:
"""Установить часовой пояс ноды."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).time.put(timezone=timezone)
return {"success": True}
except Exception as e:
logger.error(f"Error setting timezone on {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- Services ----------
def get_node_services(self, node: str) -> List[Dict]:
@@ -95,3 +253,62 @@ class NodeAdminMixin:
except Exception as e:
logger.error(f"Error adding standard repo {handle} on {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- TLS certificates ----------
def get_node_certificates(self, node: str) -> List[Dict]:
"""
Информация о TLS-сертификатах ноды.
GET /nodes/{node}/certificates/info →
[{filename, fingerprint, subject, issuer, notbefore, notafter, san,
pem, public-key-type, public-key-bits}].
"""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).certificates.info.get() or [])
except Exception as e:
logger.error(f"Error getting certificates for {node}: {e}")
return []
def upload_node_certificate(self, node: str, certificates: str, key: str = None,
force: bool = True, restart: bool = True) -> Dict:
"""
Загрузить/заменить пользовательский сертификат pveproxy (PEM).
POST /nodes/{node}/certificates/custom.
Args:
certificates: PEM-цепочка (сертификат + промежуточные)
key: приватный ключ PEM (опционально, если уже загружен)
force: перезаписать существующий пользовательский сертификат
restart: перезапустить pveproxy для применения
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
params = {
"certificates": certificates,
"force": 1 if force else 0,
"restart": 1 if restart else 0,
}
if key:
params["key"] = key
try:
self.proxmox.nodes(node).certificates.custom.post(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error uploading certificate on {node}: {e}")
return {"success": False, "error": str(e)}
def delete_node_certificate(self, node: str, restart: bool = True) -> Dict:
"""
Удалить пользовательский сертификат (вернуться к самоподписанному).
DELETE /nodes/{node}/certificates/custom.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).certificates.custom.delete(restart=1 if restart else 0)
return {"success": True}
except Exception as e:
logger.error(f"Error deleting certificate on {node}: {e}")
return {"success": False, "error": str(e)}

View File

@@ -1,4 +1,4 @@
from typing import List, Dict, Optional, Union, Any
from typing import List, Dict, Optional, Union, Any, BinaryIO
import time
import urllib3
from loguru import logger
@@ -119,6 +119,46 @@ class StorageMixin:
logger.error(f"Ошибка загрузки {url} на {node}:{storage}: {e}")
raise
def upload_file(self, node: str, storage: str, content: str, filename: str,
file_obj: BinaryIO, checksum: Optional[str] = None,
checksum_algorithm: Optional[str] = None) -> Optional[str]:
"""
Загрузить локальный файл (ISO / vztmpl) прямо в хранилище ноды через
POST /nodes/{node}/storage/{storage}/upload (multipart).
proxmoxer сам детектирует io.IOBase-значения в data и переносит их в
files, используя requests_toolbelt для потоковой отправки больших
файлов (иначе есть риск упереться в 2 GiB лимит SSL-буфера). Имя файла
в хранилище Proxmox берёт из file_obj.name — поэтому file_obj должен
быть открыт из пути, чей basename уже равен нужному filename.
Args:
node: имя ноды
storage: целевое хранилище
content: тип контента ('iso' или 'vztmpl')
filename: имя файла в хранилище (для логов; фактическое имя Proxmox
берёт из file_obj.name)
file_obj: открытый бинарный файл (basename == filename)
checksum: контрольная сумма (опционально)
checksum_algorithm: алгоритм (md5/sha1/sha224/sha256/sha384/sha512)
Returns:
UPID задачи или None
"""
if not self.proxmox:
return None
try:
params = {'content': content, 'filename': file_obj}
if checksum and checksum_algorithm:
params['checksum'] = checksum
params['checksum-algorithm'] = checksum_algorithm
result = self.proxmox.nodes(node).storage(storage).upload.post(**params)
logger.info(f"Загружен файл {filename} ({content}) на {node}:{storage}, UPID: {result}")
return result
except Exception as e:
logger.error(f"Ошибка загрузки файла {filename} на {node}:{storage}: {e}")
raise
def volume_exists(self, node: str, storage: str, volid: str) -> bool:
"""Проверить, есть ли уже том volid в хранилище (для идемпотентной загрузки)."""
if not self.proxmox:
@@ -130,6 +170,21 @@ class StorageMixin:
logger.debug(f"volume_exists check failed {storage}/{volid}: {e}")
return False
def delete_volume(self, node: str, storage: str, volid: str) -> bool:
"""Удалить том из хранилища. volid вида 'storage:content/filename'.
Возвращает True при успехе, False при ошибке."""
if not self.proxmox:
return False
# proxmoxer принимает volume без префикса хранилища
volume = volid.split(':', 1)[1] if ':' in volid else volid
try:
self.proxmox.nodes(node).storage(storage).content(volume).delete()
logger.info(f"Deleted volume {volid} from {node}:{storage}")
return True
except Exception as e:
logger.warning(f"delete_volume failed {node}:{storage}/{volume}: {e}")
return False
def get_download_target_storages(self, node: str, content: str) -> List[Dict]:
"""
Получить активные хранилища ноды, поддерживающие указанный тип контента
@@ -152,6 +207,38 @@ class StorageMixin:
logger.error(f"Ошибка получения хранилищ ({content}) ноды {node}: {e}")
return result
def get_storage_node_status(self) -> Dict[str, List[Dict[str, Any]]]:
"""
Доступность каждого хранилища по нодам — одним запросом /cluster/resources.
Конфиг кластера (/storage) не знает, поднялось ли хранилище на конкретной
ноде: VG/пул может быть объявлен, но физически отсутствовать — типичный
случай после ввода новой ноды в кластер, где локальные хранилища ноды
заменяются кластерным конфигом.
Хранилища, отключённые в конфиге (disable), в /cluster/resources не
попадают, поэтому в карте их не будет.
Returns:
{storage_id: [{'node': 'prod', 'active': True}, ...]}
"""
if not self.proxmox:
return {}
status: Dict[str, List[Dict[str, Any]]] = {}
try:
for res in self.get_cluster_resources(type_='storage'):
storage_id = res.get('storage')
node = res.get('node')
if not storage_id or not node:
continue
status.setdefault(storage_id, []).append({
'node': node,
'active': res.get('status') == 'available',
})
except Exception as e:
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
return status
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
"""Get storages available on a specific node, optionally filtered by content type"""
if not self.proxmox:

View File

@@ -1,4 +1,5 @@
from typing import List, Dict, Optional, Union, Any
import re
import time
import urllib3
from loguru import logger
@@ -40,6 +41,46 @@ class VmMixin:
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
return None
def _wait_vm_stopped(self, node: str, vmid: int, timeout: int) -> bool:
"""Опрашивать статус ВМ, пока не 'stopped' или не выйдет timeout секунд."""
deadline = time.time() + timeout
while time.time() < deadline:
st = self.get_vm_status(node, vmid) or {}
if st.get('status') == 'stopped':
return True
time.sleep(1)
return False
def hybrid_restart_vm(self, node: str, vmid: int,
graceful_timeout: int = 20) -> bool:
"""Перезапуск «мягко, при таймауте — жёстко», затем start.
status/shutdown с forceStop=1 сначала шлёт ACPI-выключение, а если гость
не ответил за graceful_timeout секунд — Proxmox сам убивает процесс QEMU.
Это покрывает и живую ОС (корректное выключение), и свежий установщик без
ACPI/QEMU Guest Agent (где обычный reboot виснет на guest-ping). Нужен для
применения нового boot order. Возвращает True, если ВМ снова запущена.
"""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(
timeout=graceful_timeout, forceStop=1)
# Ждём с запасом сверх graceful_timeout (форс-стоп внутри shutdown).
if not self._wait_vm_stopped(node, vmid, graceful_timeout + 20):
# Подстраховка: shutdown завис — бьём по процессу напрямую.
logger.warning(f"VM {vmid}: shutdown не завершился, принудительный stop")
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
if not self._wait_vm_stopped(node, vmid, 15):
logger.error(f"VM {vmid} не остановилась — старт отменён")
return False
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
logger.info(f"VM {vmid} на {node}: гибридный перезапуск выполнен, UPID: {upid}")
return isinstance(upid, str)
except Exception as e:
logger.error(f"Ошибка гибридного перезапуска VM {vmid} на {node}: {e}")
return False
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox:
@@ -101,6 +142,86 @@ class VmMixin:
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
return False
def create_vm_from_iso(self, node: str, vmid: int, name: str,
memory: int, cores: int,
disk_storage: str, disk_size: int,
iso_volid: Optional[str] = None,
bridge: str = 'vmbr0',
ostype: str = 'l26',
disk_bus: str = 'scsi',
net_model: str = 'virtio',
bios: str = 'seabios',
tpm: bool = False,
extra_iso_volid: Optional[str] = None,
onboot: bool = False,
description: Optional[str] = None) -> Optional[str]:
"""
Создать «пустую» ВМ под установку ОС с ISO.
В отличие от create_vm_with_import диск не импортируется из образа,
а аллоцируется пустым (`<storage>:<size_gb>`), ISO подключается
CD-ROM'ом на ide2 и ставится первым в порядке загрузки. Консоль —
графическая (vga=std), иначе установщик не будет виден в noVNC.
Args:
disk_size: размер пустого диска в GB
iso_volid: volid установочного ISO ('local:iso/ubuntu.iso'); None — без ISO
disk_bus: шина диска — scsi | sata | virtio (ide занят CD-ROM'ом)
net_model: модель сетевой карты (virtio | e1000 | rtl8139)
bios: seabios | ovmf (UEFI — добавляется efidisk0 и machine=q35)
tpm: добавить TPM 2.0 (требуется для Windows 11)
extra_iso_volid: второй ISO на ide0 (например, virtio-win с драйверами)
Returns:
UPID задачи создания ВМ
Исключение пробрасывается наверх — воркеру нужен текст ошибки Proxmox.
"""
if not self.proxmox:
return None
bus = disk_bus if disk_bus in ('scsi', 'sata', 'virtio') else 'scsi'
disk_dev = f'{bus}0'
disk_opts = ',discard=on' + (',iothread=1' if bus in ('scsi', 'virtio') else '')
params = {
'vmid': vmid,
'name': name,
'memory': memory,
'cores': cores,
'sockets': 1,
'cpu': 'host',
'ostype': ostype,
'scsihw': 'virtio-scsi-single',
disk_dev: f'{disk_storage}:{disk_size}{disk_opts}',
'net0': f'{net_model},bridge={bridge},firewall=1',
'agent': 'enabled=1',
'vga': 'std',
'onboot': 1 if onboot else 0,
}
if iso_volid:
params['ide2'] = f'{iso_volid},media=cdrom'
params['boot'] = f'order=ide2;{disk_dev}'
else:
params['boot'] = f'order={disk_dev}'
if extra_iso_volid:
# ide0 — второй привод под ISO с драйверами (ide2 занят основным)
params['ide0'] = f'{extra_iso_volid},media=cdrom'
if bios == 'ovmf':
params['bios'] = 'ovmf'
params['machine'] = 'q35'
# Ключи Secure Boot нужны Windows и мешают части Linux-установщиков
pre_enrolled = 1 if ostype.startswith('win') else 0
params['efidisk0'] = f'{disk_storage}:1,efitype=4m,pre-enrolled-keys={pre_enrolled}'
if tpm:
params['tpmstate0'] = f'{disk_storage}:1,version=v2.0'
if description:
params['description'] = description
result = self.proxmox.nodes(node).qemu.post(**params)
logger.info(f"Создание ВМ {vmid} ({name}) под установку с {iso_volid or 'без ISO'} на {node}, UPID: {result}")
return result
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
"""Подключить ISO образ к VM (только KVM)."""
if not self.proxmox:
@@ -124,6 +245,81 @@ class VmMixin:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False
def _ordered_boot_disks(self, cfg: Dict,
exclude_device: Optional[str] = None) -> List[str]:
"""Загрузочные диски ВМ (не CD-ROM, не пустые) в порядке текущего boot.
Порядок между дисками берём из текущего boot=order=...; недостающие
добавляем в конец. exclude_device исключается принудительно. efidisk/
tpmstate не проходят regex дисковых устройств, поэтому не попадают.
"""
disk_re = re.compile(r'^(?:scsi|virtio|sata|ide)\d+$')
disks: List[str] = []
for key, val in cfg.items():
if not disk_re.match(key):
continue
if exclude_device and key == exclude_device:
continue
if not isinstance(val, str) or not val:
continue
if 'media=cdrom' in val or val.startswith('none'):
continue
disks.append(key)
ordered: List[str] = []
m = re.search(r'order=([^,]+)', str(cfg.get('boot') or ''))
if m:
for dev in m.group(1).split(';'):
if dev in disks and dev not in ordered:
ordered.append(dev)
for dev in disks:
if dev not in ordered:
ordered.append(dev)
return ordered
def set_boot_disk_first(self, node: str, vmid: int,
exclude_device: Optional[str] = None) -> bool:
"""Поставить дисковые устройства первыми в порядке загрузки, исключив CD-ROM.
Используется после установки ОС: извлекли ISO — грузимся с диска.
exclude_device (например отключаемый ide2) исключается из рассмотрения.
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
ordered = self._ordered_boot_disks(cfg, exclude_device=exclude_device)
if not ordered:
logger.warning(f"VM {vmid}: не найден загрузочный диск для boot order")
return False
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(ordered)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → диск ({';'.join(ordered)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (диск) у VM {vmid}: {e}")
return False
def set_boot_iso_first(self, node: str, vmid: int,
iso_device: str = 'ide2') -> bool:
"""Поставить CD-ROM (ISO) первым в порядке загрузки, диски — следом.
Используется при монтировании ISO на работающую ВМ, когда нужно
загрузиться именно с образа (live-CD, восстановление, переустановка).
iso_device — привод с ISO (ide2 по умолчанию).
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
disks = self._ordered_boot_disks(cfg) # CD-ROM сюда не попадёт
order = [iso_device] + [d for d in disks if d != iso_device]
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(order)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → ISO ({';'.join(order)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (ISO) у VM {vmid}: {e}")
return False
def force_stop_vm(self, node: str, vmid: int) -> bool:
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
if not self.proxmox or not self.host:
@@ -176,6 +372,9 @@ class VmMixin:
try:
result = self.proxmox.nodes(node).qemu(vmid).agent('get-fsinfo').get()
fslist = result if isinstance(result, list) else result.get('result', [])
_SKIP_MOUNTPOINT_PREFIXES = ('/snap/', '/boot/')
_SKIP_DISK_PREFIXES = ('loop',)
disks = []
seen_disks: set = set()
for fs in fslist:
@@ -184,14 +383,19 @@ class VmMixin:
total = fs.get('total-bytes', 0) or 0
if total == 0:
continue
# Deduplicate by disk+partition key to avoid pseudo-filesystems
disk_key = fs.get('name', '') or fs.get('mountpoint', '')
mountpoint = fs.get('mountpoint', '/')
name = fs.get('name', '')
if any(mountpoint.startswith(p) for p in _SKIP_MOUNTPOINT_PREFIXES):
continue
if any(name.lower().startswith(p) for p in _SKIP_DISK_PREFIXES):
continue
disk_key = name or mountpoint
if disk_key in seen_disks:
continue
seen_disks.add(disk_key)
disks.append({
'name': fs.get('name', ''),
'mountpoint': fs.get('mountpoint', '/'),
'name': name,
'mountpoint': mountpoint,
'used': fs.get('used-bytes', 0) or 0,
'total': total,
})
@@ -331,6 +535,44 @@ class VmMixin:
logger.error(f"Ошибка добавления диска {disk} к VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def attach_vm_physical_disk(self, node: str, vmid: int, disk: str, devpath: str,
aio: Optional[str] = None, discard: bool = False,
ssd: bool = False, serial: Optional[str] = None) -> Dict:
"""Пробросить физический диск ноды в VM (raw device passthrough).
В отличие от add_vm_disk, значение конфига — абсолютный путь устройства
(`/dev/disk/by-id/...`), а не аллокация storage:size. Эквивалент
`qm set <vmid> -<disk> /dev/disk/by-id/...`.
Args:
disk: имя устройства (scsi1, virtio1, sata1, ...) — должно быть свободно
devpath: абсолютный путь блочного устройства (желательно /dev/disk/by-id/*)
aio: io_uring | native | threads (опц.)
discard/ssd: опциональные флаги
serial: серийный номер для гостя (опц.)
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
# Защита от инъекции произвольного volid: пробрасываем только пути под /dev/.
if not isinstance(devpath, str) or not devpath.startswith("/dev/"):
return {"success": False, "error": "Недопустимый путь устройства (ожидается /dev/...)"}
try:
spec = devpath
if aio in ("io_uring", "native", "threads"):
spec += f",aio={aio}"
if discard:
spec += ",discard=on"
if ssd:
spec += ",ssd=1"
if serial:
spec += f",serial={serial}"
self.proxmox.nodes(node).qemu(vmid).config.post(**{disk: spec})
logger.info(f"Проброшен физический диск {disk} ({spec}) в VM {vmid}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка проброса физического диска {disk} в VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def detach_vm_disk(self, node: str, vmid: int, disk: str, destroy: bool = False) -> Dict:
"""Отключить диск VM. destroy=True — также физически удалить том.

View File

@@ -0,0 +1,130 @@
"""Automatic API token provisioning for newly connected Proxmox servers.
The panel prefers token auth over storing a root password: a token can be
revoked from the PVE UI, cannot be used for SSH or web login, and shows up in
the PVE audit log under its own name. This module connects once with the
supplied password, creates a dedicated token, and hands its credentials back to
the caller — see ``api/proxmox/servers.py`` for the create/migrate endpoints.
"""
from typing import Optional, Tuple
from loguru import logger
from .client import ProxmoxClient
# Token id created on the Proxmox side. Kept stable so a returning user
# recognises it in Datacenter → Permissions → API Tokens.
DEFAULT_TOKEN_NAME = "pvemanager"
# PVE rejects token ids outside [A-Za-z0-9.\-_], so suffixes stay numeric.
MAX_NAME_ATTEMPTS = 50
class TokenProvisionError(Exception):
"""Raised when the panel could not create an API token on the server."""
def build_host(ip_address: str, port: Optional[int]) -> str:
"""Host string for ProxmoxClient — appends a non-default port."""
host = ip_address or ""
if port and port != 8006:
return f"{host}:{port}"
return host
def pick_token_name(existing: set, base: str = DEFAULT_TOKEN_NAME) -> str:
"""Pick a free token id, suffixing when the preferred name is taken.
Reusing an existing token is not an option: PVE returns the secret only at
creation time, so an already-present ``pvemanager`` token likely belongs to
another panel instance and must be left alone.
"""
if base not in existing:
return base
for i in range(2, MAX_NAME_ATTEMPTS + 1):
candidate = f"{base}-{i}"
if candidate not in existing:
return candidate
raise TokenProvisionError(
f"Could not find a free API token name after {MAX_NAME_ATTEMPTS} attempts"
)
def provision_api_token(
ip_address: str,
port: Optional[int],
api_user: str,
password: str,
verify_ssl: bool = False,
comment: str = "Created automatically by PVEmanager",
privsep: bool = False,
timeout: int = 15,
) -> Tuple[str, str]:
"""Create an API token on the Proxmox server using password auth.
Returns ``(token_name, token_value)``. The secret is returned by PVE exactly
once, so the caller must persist it or the token becomes unusable.
Raises TokenProvisionError when the server is unreachable, the credentials
are wrong, or the user lacks permission to create tokens.
"""
if not password:
raise TokenProvisionError("A password is required to create an API token")
client = ProxmoxClient(
host=build_host(ip_address, port),
user=api_user,
password=password,
verify_ssl=verify_ssl,
timeout=timeout,
)
if not client.is_connected():
raise TokenProvisionError(
"Could not authenticate to the Proxmox server with the supplied password"
)
existing = {t.get("tokenid") for t in client.get_user_tokens(api_user)}
token_name = pick_token_name(existing)
# privsep=0 grants the token the same privileges as the user itself; with
# privsep=1 it would start with no permissions until ACLs are assigned.
result = client.create_user_token(api_user, token_name, comment=comment, privsep=privsep)
if not result.get("success") or not result.get("value"):
raise TokenProvisionError(
result.get("error") or "Proxmox did not return a token secret"
)
logger.info(f"Created Proxmox API token '{api_user}!{token_name}' on {ip_address}")
return token_name, result["value"]
def revoke_api_token(
ip_address: str,
port: Optional[int],
api_user: str,
password: str,
token_name: str,
verify_ssl: bool = False,
timeout: int = 15,
) -> bool:
"""Best-effort cleanup of a token the panel created but failed to persist.
Without this, a failed server registration leaves an orphaned token on the
node that nobody can use (its secret was never stored).
"""
try:
client = ProxmoxClient(
host=build_host(ip_address, port),
user=api_user,
password=password,
verify_ssl=verify_ssl,
timeout=timeout,
)
if not client.is_connected():
return False
result = client.delete_user_token(api_user, token_name)
return bool(result.get("success"))
except Exception as e:
logger.warning(f"Failed to revoke orphaned token '{token_name}' on {ip_address}: {e}")
return False

View File

@@ -37,6 +37,7 @@ NEW_DEFAULT_ROLES = [
"vm:restart": True,
"vm:console": True,
"vm:migrate": True,
"vm:remote_migrate": True,
"vm:execute": True,
"vm:manage": True,
# LXC
@@ -49,6 +50,7 @@ NEW_DEFAULT_ROLES = [
"lxc:restart": True,
"lxc:console": True,
"lxc:migrate": True,
"lxc:remote_migrate": True,
# Templates
"template:view": True,
"template:create": True,
@@ -64,10 +66,11 @@ NEW_DEFAULT_ROLES = [
# Networking (interfaces + SDN)
"network:view": True,
"network:manage": True,
# Node administration (services, APT, upgrade)
# Node administration (services, APT, upgrade, power)
"node:view": True,
"node:manage": True,
"node:upgrade": True,
"node:power": True,
# Resource pools
"pool:view": True,
"pool:manage": True,
@@ -173,7 +176,8 @@ NEW_DEFAULT_ROLES = [
"node:view": True,
"pool:view": True,
"ipam:view": True,
"setting:view": True,
# No setting:view — that grants the panel-wide settings page. A
# user's own profile lives at /profile and needs no permission.
"notification:view": True,
"notification:manage": True,
}
@@ -490,7 +494,7 @@ GRANULAR_BACKFILL = [
("server:manage", [
"firewall:view", "firewall:manage",
"network:view", "network:manage",
"node:view", "node:manage", "node:upgrade",
"node:view", "node:manage", "node:upgrade", "node:power",
"storage:view", "storage:manage",
"pool:view", "pool:manage",
]),

View File

@@ -311,6 +311,13 @@ def _create_permissions() -> PermissionRegistry:
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="remote_migrate",
display_name="Remote Migrate VMs",
description="Migrate VMs to a different, independently registered Proxmox cluster",
category="Virtual Machines",
requires=["vm:migrate"]
))
registry.register(Permission(
resource="vm", action="execute",
display_name="Execute Commands",
@@ -389,7 +396,14 @@ def _create_permissions() -> PermissionRegistry:
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="remote_migrate",
display_name="Remote Migrate Containers",
description="Migrate containers to a different, independently registered Proxmox cluster",
category="Containers",
requires=["lxc:migrate"]
))
# Template permissions
registry.register(Permission(
resource="template", action="view",
@@ -602,17 +616,19 @@ def _create_permissions() -> PermissionRegistry:
requires=["log:view"]
))
# Settings permissions
# Settings permissions. These cover panel-wide configuration only — a user's
# own account (profile, password, 2FA, SSH keys, notification preferences)
# is self-scoped and needs no permission at all.
registry.register(Permission(
resource="setting", action="view",
display_name="View Settings",
description="View panel settings",
display_name="View Panel Settings",
description="View panel-wide settings (not needed for one's own profile)",
category="Settings"
))
registry.register(Permission(
resource="setting", action="update",
display_name="Update Settings",
description="Modify panel settings",
display_name="Update Panel Settings",
description="Modify panel-wide settings",
category="Settings",
requires=["setting:view"]
))
@@ -690,6 +706,13 @@ def _create_permissions() -> PermissionRegistry:
category="Node Administration",
requires=["node:view"]
))
registry.register(Permission(
resource="node", action="power",
display_name="Power Control Node",
description="Reboot or shut down the physical node",
category="Node Administration",
requires=["node:view"]
))
# Resource pool permissions
registry.register(Permission(
@@ -706,6 +729,50 @@ def _create_permissions() -> PermissionRegistry:
requires=["pool:view"]
))
# App Store permissions (каталог self-hosted приложений)
registry.register(Permission(
resource="app", action="view",
display_name="View App Store",
description="Просмотр каталога приложений и установленных приложений",
category="App Store"
))
registry.register(Permission(
resource="app", action="install",
display_name="Install Apps",
description="Установка приложений из каталога (создание LXC)",
category="App Store",
requires=["app:view"]
))
registry.register(Permission(
resource="app", action="manage",
display_name="Manage App Store",
description="Синхронизация каталога, обновление/удаление приложений",
category="App Store",
requires=["app:view"]
))
# Script catalog permissions (bash-скрипты на нодах/гостях)
registry.register(Permission(
resource="script", action="view",
display_name="View Scripts",
description="Просмотр каталога скриптов и истории запусков",
category="Scripts"
))
registry.register(Permission(
resource="script", action="execute",
display_name="Execute Scripts",
description="Запуск скриптов из каталога на нодах/ВМ/LXC",
category="Scripts",
requires=["script:view"]
))
registry.register(Permission(
resource="script", action="manage",
display_name="Manage Scripts",
description="Создание/редактирование скриптов и git-источников каталога",
category="Scripts",
requires=["script:view"]
))
# PVE access permissions (authentication realms & API tokens)
registry.register(Permission(
resource="access", action="view",

View File

@@ -1,10 +1,36 @@
from datetime import datetime
from typing import Optional, List
import ipaddress
import re
from pydantic import BaseModel, Field, field_validator, model_validator, EmailStr
_HOSTNAME_LABEL_RE = re.compile(r'^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$')
def validate_host(v: str) -> str:
"""Validate that value is a valid IP address or hostname/FQDN."""
if v is None:
raise ValueError('Host is required')
v = v.strip()
if not v:
raise ValueError('Host cannot be empty')
# Accept valid IP address (v4 or v6)
try:
ipaddress.ip_address(v)
return v
except ValueError:
pass
# Otherwise validate as hostname / FQDN
host = v.rstrip('.')
if len(host) > 253 or not host:
raise ValueError('Invalid host: must be a valid IP address or hostname')
if all(_HOSTNAME_LABEL_RE.match(label) for label in host.split('.')):
return v
raise ValueError('Invalid host: must be a valid IP address or hostname')
# ==================== User Schemas ====================
class UserBase(BaseModel):
@@ -31,6 +57,7 @@ class UserResponse(UserBase):
id: int
is_active: bool
is_admin: bool
two_factor_enabled: bool = False
created_at: datetime
last_login: Optional[datetime] = None
@@ -40,8 +67,11 @@ class UserResponse(UserBase):
class Token(BaseModel):
access_token: str
# access_token is absent when the login step succeeded on password but a
# second factor is still required (two_factor_required=True).
access_token: Optional[str] = None
token_type: str = "bearer"
two_factor_required: Optional[bool] = None
class TokenData(BaseModel):
@@ -51,6 +81,38 @@ class TokenData(BaseModel):
class LoginRequest(BaseModel):
username: str = Field(..., description="Username")
password: str = Field(..., description="Password")
code: Optional[str] = Field(None, description="TOTP or backup code (when 2FA is enabled)")
# ==================== Two-Factor (2FA) Schemas ====================
class TwoFactorStatusResponse(BaseModel):
enabled: bool
backup_codes_remaining: int = 0
class TwoFactorSetupResponse(BaseModel):
secret: str
otpauth_uri: str
qr_svg: str
class TwoFactorEnableRequest(BaseModel):
code: str = Field(..., description="6-digit TOTP code from the authenticator app")
class TwoFactorEnableResponse(BaseModel):
enabled: bool = True
backup_codes: List[str]
class TwoFactorDisableRequest(BaseModel):
password: str = Field(..., description="Current account password")
code: Optional[str] = Field(None, description="Current TOTP or backup code")
class MessageResponse(BaseModel):
message: str
class ForgotPasswordRequest(BaseModel):
@@ -64,57 +126,6 @@ class ResetPasswordRequest(BaseModel):
# ==================== Proxmox Server Schemas ====================
class ProxmoxServerBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Server name")
hostname: str = Field(..., min_length=1, max_length=255, description="Server hostname/FQDN")
ip_address: str = Field(..., min_length=7, max_length=50, description="IP address")
port: int = Field(default=8006, ge=1, le=65535, description="Proxmox API port")
api_user: str = Field(default="root@pam", max_length=100, description="API username (e.g., root@pam)")
api_token_name: Optional[str] = Field(None, max_length=100, description="API token name")
api_token_value: Optional[str] = Field(None, max_length=255, description="API token value")
use_password: bool = Field(default=False, description="Use password instead of token")
password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)")
verify_ssl: bool = Field(default=False, description="Verify SSL certificate")
description: Optional[str] = Field(None, description="Server description")
@field_validator('ip_address')
@classmethod
def validate_ip_address(cls, v):
"""Validate IP address format"""
try:
ipaddress.ip_address(v)
return v
except ValueError:
raise ValueError('Invalid IP address format')
@field_validator('name')
@classmethod
def validate_name(cls, v):
"""Validate server name"""
if not v or not v.strip():
raise ValueError('Server name cannot be empty')
return v.strip()
@field_validator('hostname')
@classmethod
def validate_hostname(cls, v):
"""Validate hostname"""
if not v or not v.strip():
raise ValueError('Hostname cannot be empty')
return v.strip()
@model_validator(mode='after')
def validate_auth_config(self):
"""Validate authentication configuration"""
if self.use_password:
if not self.password:
raise ValueError('Password is required when use_password=True')
else:
if not self.api_token_name or not self.api_token_value:
raise ValueError('API token name and value are required when using token auth')
return self
class ProxmoxServerCreate(BaseModel):
"""Schema for creating a new Proxmox server"""
name: str = Field(..., min_length=1, max_length=100, description="Server name")
@@ -126,18 +137,18 @@ class ProxmoxServerCreate(BaseModel):
api_token_value: Optional[str] = Field(None, max_length=255, description="API token value")
use_password: bool = Field(default=False, description="Use password instead of token")
password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)")
auto_create_token: bool = Field(
default=False,
description="Authenticate with the password once, then let the panel create its own API token",
)
verify_ssl: bool = Field(default=False, description="Verify SSL certificate")
description: Optional[str] = Field(None, description="Server description")
@field_validator('ip_address')
@classmethod
def validate_ip_address(cls, v):
"""Validate IP address format"""
try:
ipaddress.ip_address(v)
return v
except ValueError:
raise ValueError('Invalid IP address format')
"""Validate IP address or hostname/FQDN"""
return validate_host(v)
@field_validator('name')
@classmethod
@@ -158,7 +169,12 @@ class ProxmoxServerCreate(BaseModel):
@model_validator(mode='after')
def validate_auth_config(self):
"""Validate authentication configuration"""
if self.use_password:
if self.auto_create_token:
# The token does not exist yet — it is created during registration
# using this password, so only the password is required here.
if not self.password:
raise ValueError('Password is required when auto_create_token=True')
elif self.use_password:
if not self.password:
raise ValueError('Password is required when use_password=True')
else:
@@ -182,6 +198,15 @@ class ProxmoxServerUpdate(BaseModel):
description: Optional[str] = None
class ServerWorkspaceBrief(BaseModel):
"""Compact workspace reference shown on server cards"""
id: int
name: str
color: Optional[str] = None
model_config = {"from_attributes": True}
class ProxmoxServerResponse(BaseModel):
"""Schema for Proxmox server response"""
id: int
@@ -191,12 +216,17 @@ class ProxmoxServerResponse(BaseModel):
port: int
api_user: str
verify_ssl: bool
use_password: bool = False
cluster_name: Optional[str] = None
description: Optional[str] = None
created_at: datetime
updated_at: datetime
last_check: Optional[datetime] = None
is_online: Optional[bool] = None
last_error: Optional[str] = None
# 'auth' — сервер отвечает, но отверг учётные данные; 'connection' — не отвечает
last_error_kind: Optional[str] = None
workspaces: List[ServerWorkspaceBrief] = []
# Exclude sensitive fields
api_token_value: Optional[str] = Field(None, exclude=True)
@@ -376,6 +406,8 @@ class IPAMNetworkBase(BaseModel):
proxmox_server_id: Optional[int] = Field(None, description="Associated Proxmox server ID")
proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node name (e.g., pve1)")
proxmox_bridge: Optional[str] = Field(None, max_length=20, description="Proxmox bridge (e.g., vmbr0)")
workspace_id: Optional[int] = Field(None, description="Workspace this network belongs to")
is_default: bool = Field(default=False, description="Default network within its workspace")
is_active: bool = Field(default=True, description="Is network active")
@field_validator('network')
@@ -418,6 +450,8 @@ class IPAMNetworkUpdate(BaseModel):
proxmox_server_id: Optional[int] = None
proxmox_node: Optional[str] = Field(None, max_length=100)
proxmox_bridge: Optional[str] = Field(None, max_length=20)
workspace_id: Optional[int] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
@@ -432,6 +466,7 @@ class IPAMNetworkResponse(IPAMNetworkBase):
available_ips: Optional[int] = None
utilization_percent: Optional[float] = None
server_name: Optional[str] = None
workspace_name: Optional[str] = None
model_config = {
"from_attributes": True

View File

@@ -0,0 +1,580 @@
"""
Catalog Service (M1) — конвертер runtipi/runtipi-appstore → таблица catalog_apps.
Скачивает tarball репозитория (httpx, без git-зависимости), парсит apps/<id>/,
кэширует логотипы на диск и идемпотентно upsert'ит записи в БД.
Устойчивость (ТЗ):
- F-CAT-5: битый config.json одного приложения не роняет весь синк (try/except на app).
- NF-4: отказ сети до GitHub не роняет модуль — исключение логируется, кэш в БД остаётся.
- Риск п.13: источник изолирован за интерфейсом CatalogProvider; ref фиксируется в настройках.
"""
from __future__ import annotations
import base64
import json
import os
import tarfile
import tempfile
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
import httpx
import yaml
from apscheduler.triggers.interval import IntervalTrigger
from loguru import logger
from sqlalchemy.orm import Session
from ..config import settings
from ..db import SessionLocal
from ..models import CatalogApp, InstalledApp
# Расширения логотипов в порядке предпочтения
_LOGO_NAMES = ("logo.jpg", "logo.png", "logo.jpeg", "logo.webp", "logo.svg")
# Лимиты на вспомогательные файлы приложения (data_files): защищаемся от
# гигантских бинарей в репозитории каталога — они не нужны для bind-mount'ов
# конфигов/скриптов, а раздули бы БД.
_DATA_FILE_MAX = 512 * 1024 # на один файл
_DATA_TOTAL_MAX = 4 * 1024 * 1024 # суммарно на приложение
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _logos_dir() -> Path:
d = Path(settings.APPSTORE_DATA_DIR) / "logos"
d.mkdir(parents=True, exist_ok=True)
return d
# ── DTO ───────────────────────────────────────────────────────────────────────
class AppRaw:
"""Сырые файлы одного приложения из репозитория."""
def __init__(self, app_id: str, source: str = "runtipi"):
self.app_id = app_id
self.source = source
self.config: Optional[dict] = None
self.compose: Optional[str] = None
self.description_md: Optional[str] = None
self.logo_bytes: Optional[bytes] = None
self.logo_ext: Optional[str] = None
# Файлы приложения, которые compose монтирует из ${APP_DATA_DIR}:
# {relpath: {"b64": <base64>, "exec": bool}}. Без них у ~60% приложений
# bind-mount'ы указывают на несуществующие конфиги/скрипты.
self.data_files: Dict[str, dict] = {}
self._data_total = 0
def add_data_file(self, relpath: str, data: bytes, mode: int) -> None:
"""Добавить вспомогательный файл с учётом лимитов размера."""
if len(data) > _DATA_FILE_MAX:
logger.info(f"[appstore] skip data file {self.app_id}/{relpath}: "
f"{len(data)} bytes > {_DATA_FILE_MAX}")
return
if self._data_total + len(data) > _DATA_TOTAL_MAX:
logger.info(f"[appstore] skip data file {self.app_id}/{relpath}: "
f"превышен общий лимит {_DATA_TOTAL_MAX}")
return
self.data_files[relpath] = {
"b64": base64.b64encode(data).decode("ascii"),
"exec": bool(mode & 0o100),
}
self._data_total += len(data)
# ── Провайдер источника (изоляция, риск п.13) ─────────────────────────────────
class CatalogProvider(ABC):
# источник, к которому относится провайдер (для scoping'а в sync_catalog)
source: str = "runtipi"
@abstractmethod
def fetch(self) -> Dict[str, AppRaw]:
"""Вернуть словарь app_id → AppRaw. Может бросить при сетевой ошибке."""
@staticmethod
def _download_tarball(repo: str, ref: str) -> Path:
url = f"https://github.com/{repo}/archive/{ref}.tar.gz"
logger.info(f"[appstore] downloading catalog tarball: {url}")
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
try:
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as r:
r.raise_for_status()
for chunk in r.iter_bytes(chunk_size=1 << 16):
tmp.write(chunk)
finally:
tmp.close()
return Path(tmp.name)
@staticmethod
def _read(tar: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
f = tar.extractfile(member)
return f.read() if f else b""
class RuntipiCatalogProvider(CatalogProvider):
source = "runtipi"
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None):
self.repo = repo or settings.RUNTIPI_APPSTORE_REPO
self.ref = ref or settings.RUNTIPI_APPSTORE_REF
def _download(self) -> Path:
return self._download_tarball(self.repo, self.ref)
def fetch(self) -> Dict[str, AppRaw]:
archive = self._download()
apps: Dict[str, AppRaw] = {}
try:
with tarfile.open(archive, mode="r:gz") as tar:
for member in tar:
if not member.isfile():
continue
# путь вида "runtipi-appstore-<ref>/apps/<id>/<...>"
parts = member.name.split("/")
if "apps" not in parts:
continue
ai = parts.index("apps")
if len(parts) <= ai + 2:
continue
app_id = parts[ai + 1]
rel = parts[ai + 2:] # путь внутри apps/<id>/
raw = apps.setdefault(app_id, AppRaw(app_id, source=self.source))
fname = rel[-1].lower()
try:
if rel == ["config.json"]:
raw.config = json.loads(self._read(tar, member))
elif rel == ["docker-compose.yml"]:
raw.compose = self._read(tar, member).decode("utf-8", "replace")
elif rel == ["metadata", "description.md"]:
raw.description_md = self._read(tar, member).decode("utf-8", "replace")
elif len(rel) == 2 and rel[0] == "metadata" and fname in _LOGO_NAMES:
raw.logo_bytes = self._read(tar, member)
raw.logo_ext = os.path.splitext(fname)[1] or ".jpg"
elif len(rel) >= 2 and rel[0] == "data":
# apps/<id>/data/** — файлы, которые Runtipi копирует в
# app-data и на которые compose ссылается как
# ${APP_DATA_DIR}/data/<...> (конфиги, скрипты).
raw.add_data_file("/".join(rel), self._read(tar, member),
member.mode)
except Exception as e:
logger.warning(f"[appstore] parse {app_id}/{'/'.join(rel)}: {e}")
finally:
try:
archive.unlink()
except Exception:
pass
return apps
class UmbrelCatalogProvider(CatalogProvider):
"""Провайдер каталога Umbrel (getumbrel/umbrel-apps).
Структура репозитория: `<repo>-<ref>/<app-id>/{umbrel-app.yml,
docker-compose.yml, <logo>}`. Метаданные лежат в umbrel-app.yml (а не в
compose, как у Runtipi), поэтому нормализуем их в тот же shape, что ждёт
_build_fields. app_id префиксуется "umbrel-" для изоляции PK от Runtipi.
"""
source = "umbrel"
PREFIX = "umbrel-"
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None,
gallery_cdn: Optional[str] = None):
self.repo = repo or settings.UMBREL_APPSTORE_REPO
self.ref = ref or settings.UMBREL_APPSTORE_REF
self.gallery_cdn = gallery_cdn or settings.UMBREL_APPSTORE_GALLERY_CDN
def fetch(self) -> Dict[str, AppRaw]:
archive = self._download_tarball(self.repo, self.ref)
apps: Dict[str, AppRaw] = {}
try:
with tarfile.open(archive, mode="r:gz") as tar:
for member in tar:
if not member.isfile():
continue
# путь вида "<repo>-<ref>/<app-id>/<...>"
parts = member.name.split("/")
if len(parts) < 3:
continue
raw_id = parts[1]
if not raw_id or raw_id.startswith("."):
continue # .github, .gitignore и т.п.
app_id = self.PREFIX + raw_id
fname = parts[-1].lower()
rel = "/".join(parts[2:]) # путь внутри папки приложения
raw = apps.setdefault(app_id, AppRaw(app_id, source=self.source))
try:
if rel == "umbrel-app.yml":
raw.config = self._map_manifest(
raw_id, yaml.safe_load(self._read(tar, member)))
elif rel == "docker-compose.yml":
raw.compose = self._read(tar, member).decode("utf-8", "replace")
elif len(parts) == 3 and fname in _LOGO_NAMES:
raw.logo_bytes = self._read(tar, member)
raw.logo_ext = os.path.splitext(fname)[1] or ".jpg"
elif len(parts) == 3 and fname.startswith(("icon.", "gallery")):
pass # ассеты витрины — не файлы приложения
else:
# Остальные файлы (включая вложенные, напр. data/**,
# entrypoint.sh) Umbrel копирует в app-data приложения,
# а compose монтирует их как ${APP_DATA_DIR}/<relpath>.
raw.add_data_file(rel, self._read(tar, member), member.mode)
except Exception as e:
logger.warning(f"[appstore] parse umbrel {app_id}/{fname}: {e}")
finally:
try:
archive.unlink()
except Exception:
pass
# Иконки — из галереи Umbrel (<app-id>/icon.svg), пофайлово с CDN. Сбой
# получения иконок не должен ронять синк каталога.
try:
raw_ids = [app_id[len(self.PREFIX):] for app_id in apps]
icons = self._fetch_gallery_icons(raw_ids)
for app_id, raw in apps.items():
data = icons.get(app_id[len(self.PREFIX):])
if data:
raw.logo_bytes = data
raw.logo_ext = ".svg"
logger.info(f"[appstore] umbrel icons fetched: {len(icons)}/{len(apps)}")
except Exception as e:
logger.warning(f"[appstore] umbrel gallery icons failed: {e}")
return apps
def _fetch_gallery_icons(self, raw_ids: List[str]) -> Dict[str, bytes]:
"""Скачать icon.svg каждого приложения параллельно с CDN галереи Umbrel.
Быстрее тяжёлого tarball галереи (тот тянет и скриншоты). HTTP 404 → у
приложения нет иконки (не ошибка); сетевой сбой отдельного запроса просто
пропускает иконку, не роняя синк.
"""
base = (self.gallery_cdn or "").rstrip("/")
if not base or not raw_ids:
return {}
def _one(raw_id: str):
url = f"{base}/{raw_id}/icon.svg"
try:
r = httpx.get(url, timeout=15, follow_redirects=True)
if r.status_code == 200 and r.content:
return raw_id, r.content
except Exception:
return None
return None
icons: Dict[str, bytes] = {}
with ThreadPoolExecutor(max_workers=16, thread_name_prefix="umbrel_icons") as ex:
for res in ex.map(_one, raw_ids):
if res:
icons[res[0]] = res[1]
return icons
@staticmethod
def _map_manifest(raw_id: str, meta: Optional[dict]) -> Optional[dict]:
"""umbrel-app.yml → config-словарь в формате, ожидаемом _build_fields."""
if not isinstance(meta, dict):
return None
cat = meta.get("category")
categories = [str(cat)] if cat else []
port = meta.get("port")
# Учётные данные по умолчанию: без них пользователю после установки
# неоткуда узнать логин/пароль приложения (Umbrel показывает их в своём
# UI). deterministicPassword: true — пароль генерирует рантайм и
# передаёт через APP_PASSWORD (у нас — сгенерированный при установке).
default_creds = None
du = str(meta.get("defaultUsername") or "").strip()
dp = str(meta.get("defaultPassword") or "").strip()
deterministic = bool(meta.get("deterministicPassword"))
if du or dp or deterministic:
default_creds = {"username": du or None, "password": dp or None,
"deterministic": deterministic}
return {
"name": meta.get("name") or raw_id,
"version": str(meta.get("version")) if meta.get("version") is not None else None,
# у Umbrel нет целочисленной версии рецепта — update_available по
# tipi_version для umbrel-приложений не вычисляется (см. _refresh_update_flags).
"tipi_version": None,
"categories": categories,
"short_desc": meta.get("tagline"),
"description": meta.get("description"),
"port": port if isinstance(port, int) else None,
"form_fields": [], # Umbrel не описывает поля формы декларативно
"supported_architectures": [], # обычно multi-arch — не ограничиваем
"available": True,
"deprecated": False,
"dynamic_config": False,
"source": meta.get("repo") or meta.get("website"),
"author": meta.get("developer") or meta.get("submitter"),
"default_credentials": default_creds,
}
# ── Синхронизация ─────────────────────────────────────────────────────────────
def _host_arch() -> str:
return (settings.APPSTORE_HOST_ARCH or "amd64").strip().lower()
def _save_logo(app_id: str, raw: AppRaw) -> Optional[str]:
if not raw.logo_bytes:
return None
ext = raw.logo_ext or ".jpg"
fname = f"{app_id}{ext}"
(_logos_dir() / fname).write_bytes(raw.logo_bytes)
return f"logos/{fname}"
def _build_fields(app_id: str, raw: AppRaw) -> Optional[dict]:
"""Смаппить AppRaw → поля CatalogApp. None → приложение пропускается (F-CAT-3)."""
cfg = raw.config
if not cfg:
return None
if cfg.get("deprecated") is True:
return None
if not raw.compose:
logger.info(f"[appstore] skip {app_id}: нет docker-compose.yml")
return None
# F-CAT-6: проверка, что compose — валидный YAML
try:
yaml.safe_load(raw.compose)
except Exception as e:
logger.info(f"[appstore] skip {app_id}: невалидный compose YAML: {e}")
return None
archs = cfg.get("supported_architectures") or []
available = bool(cfg.get("available", True))
reason = None
if not available:
reason = "помечено недоступным в источнике"
elif archs and _host_arch() not in [str(a).lower() for a in archs]:
available = False
reason = f"нет поддержки арх. {_host_arch()}"
return {
"source": raw.source or "runtipi",
"name": cfg.get("name") or app_id,
"version": str(cfg.get("version")) if cfg.get("version") is not None else None,
"tipi_version": cfg.get("tipi_version") if isinstance(cfg.get("tipi_version"), int) else None,
"categories": cfg.get("categories") or [],
"short_desc": cfg.get("short_desc"),
"description_md": raw.description_md or cfg.get("description"),
"port": cfg.get("port") if isinstance(cfg.get("port"), int) else None,
"form_fields": cfg.get("form_fields") or [],
"compose_yaml": raw.compose,
"data_files": raw.data_files or None,
"default_credentials": cfg.get("default_credentials"),
"architectures": archs,
"available": available,
"unavailable_reason": reason,
"deprecated": bool(cfg.get("deprecated", False)),
"dynamic_config": bool(cfg.get("dynamic_config", False)),
"source_url": cfg.get("source"),
"author": cfg.get("author"),
}
def _enabled_providers(ref: Optional[str] = None) -> List[CatalogProvider]:
"""Список провайдеров по настройке APPSTORE_SOURCES."""
raw = (settings.APPSTORE_SOURCES or "runtipi")
names = [s.strip().lower() for s in raw.split(",") if s.strip()]
providers: List[CatalogProvider] = []
for n in names:
if n == "runtipi":
providers.append(RuntipiCatalogProvider(ref=ref))
elif n == "umbrel":
# ref относится к runtipi (совместимость со старой сигнатурой) — Umbrel
# берёт собственный UMBREL_APPSTORE_REF.
providers.append(UmbrelCatalogProvider())
else:
logger.warning(f"[appstore] unknown catalog source '{n}', пропущен")
if not providers:
providers.append(RuntipiCatalogProvider(ref=ref))
return providers
def sync_catalog(db: Session, ref: Optional[str] = None,
provider: Optional[CatalogProvider] = None) -> dict:
"""Идемпотентная синхронизация каталога из всех активных источников.
Каждый источник синхронизируется независимо: сетевой сбой одного не отменяет
остальные, а пометка «исчезнувших» приложений scoped'ится по source, чтобы
синк runtipi не задел записи umbrel и наоборот.
"""
providers = [provider] if provider is not None else _enabled_providers(ref)
started = _utcnow()
created = updated = skipped = errors = disappeared = 0
source_errors: List[str] = []
for prov in providers:
try:
apps = prov.fetch()
except Exception as e: # NF-4 — источник недоступен, не роняем остальные
source_errors.append(f"{prov.source}: {e}")
logger.warning(f"[appstore] fetch source '{prov.source}' failed: {e}")
continue
seen: List[str] = []
for app_id, raw in apps.items():
try:
fields = _build_fields(app_id, raw)
if fields is None:
skipped += 1
continue
fields["logo_path"] = _save_logo(app_id, raw)
fields["synced_at"] = _utcnow()
existing = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if existing:
for k, v in fields.items():
if k == "logo_path" and v is None:
continue # не затирать существующий логотип
setattr(existing, k, v)
updated += 1
else:
db.add(CatalogApp(app_id=app_id, **fields))
created += 1
seen.append(app_id)
db.commit()
except Exception as e: # F-CAT-5 — не роняем весь синк
errors += 1
db.rollback()
logger.warning(f"[appstore] sync {app_id} failed: {e}")
# Исчезнувшие из ЭТОГО источника → недоступны (scoped по source).
if seen:
disappeared += (
db.query(CatalogApp)
.filter(
CatalogApp.source == prov.source,
~CatalogApp.app_id.in_(seen),
CatalogApp.available.is_(True),
)
.update(
{CatalogApp.available: False,
CatalogApp.unavailable_reason: "удалено из источника"},
synchronize_session=False,
)
)
db.commit()
# F-CAT-4: выставить update_available для установленных приложений
flagged = _refresh_update_flags(db)
total = db.query(CatalogApp).count()
stats = {
"created": created, "updated": updated, "skipped": skipped,
"errors": errors, "disappeared": disappeared, "flagged": flagged, "total": total,
"sources": [p.source for p in providers if p is not None],
"source_errors": source_errors,
"ref": (ref or settings.RUNTIPI_APPSTORE_REF),
"duration_sec": round((_utcnow() - started).total_seconds(), 1),
"synced_at": _utcnow().isoformat(),
}
logger.info(f"[appstore] catalog sync done: {stats}")
return stats
def _refresh_update_flags(db: Session) -> int:
"""Сравнить tipi_version каталога с установленными → выставить update_available (F-CAT-4)."""
catalog = {c.app_id: c for c in db.query(CatalogApp).all()}
changed = 0
for ia in db.query(InstalledApp).all():
c = catalog.get(ia.app_id)
avail = bool(
c and c.tipi_version and ia.tipi_version_installed
and c.tipi_version > ia.tipi_version_installed
)
if ia.update_available != avail:
ia.update_available = avail
changed += 1
if changed:
db.commit()
return changed
def get_catalog_meta(db: Session) -> dict:
total = db.query(CatalogApp).count()
last = (
db.query(CatalogApp.synced_at)
.order_by(CatalogApp.synced_at.desc())
.first()
)
cats = set()
for (arr,) in db.query(CatalogApp.categories).all():
for c in (arr or []):
cats.add(c)
sources = sorted(
s for (s,) in db.query(CatalogApp.source).distinct().all() if s
)
return {
"total": total,
"last_synced_at": last[0].isoformat() if last and last[0] else None,
"repo": settings.RUNTIPI_APPSTORE_REPO,
"ref": settings.RUNTIPI_APPSTORE_REF,
"categories": sorted(cats),
"sources": sources,
}
# ── Планировщик (F-CAT-1: авто раз в 24ч) ─────────────────────────────────────
def _scheduled_sync() -> None:
db = SessionLocal()
try:
sync_catalog(db)
except Exception as e: # NF-4 — не роняем планировщик, кэш остаётся
logger.warning(f"[appstore] scheduled catalog sync failed: {e}")
finally:
db.close()
def start_catalog_scheduler():
"""Зарегистрировать периодическую синхронизацию в общем APScheduler. Из lifespan."""
from .backup_scheduler import get_scheduler
aps = get_scheduler()
aps.add_job(
_scheduled_sync,
trigger=IntervalTrigger(hours=settings.CATALOG_SYNC_INTERVAL_HOURS),
id="appstore_catalog_sync",
replace_existing=True,
misfire_grace_time=3600,
)
# Стартовый синк: на свежем деплое каталог пуст, а IntervalTrigger впервые
# сработает только через CATALOG_SYNC_INTERVAL_HOURS. Чтобы не ждать 24ч,
# запускаем разовый синк немедленно в фоне (не блокируя старт приложения).
try:
db = SessionLocal()
try:
is_empty = db.query(CatalogApp).count() == 0
finally:
db.close()
if is_empty:
aps.add_job(
_scheduled_sync,
trigger="date", # run_date по умолчанию = сейчас → разовый запуск
id="appstore_catalog_initial_sync",
replace_existing=True,
misfire_grace_time=600,
)
logger.info("App Store catalog empty — initial sync scheduled")
except Exception as e:
logger.warning(f"App Store initial catalog sync check failed: {e}")
if not aps.running:
aps.start()
logger.info(
f"App Store catalog scheduler registered (every {settings.CATALOG_SYNC_INTERVAL_HOURS}h)"
)
return aps

View File

@@ -0,0 +1,895 @@
"""
App Engine (M2) — жизненный цикл приложения поверх пайплайна M0.
Установка: клон золотого шаблона → pct push compose/.env → docker compose up →
health-check, с журналом шагов (app_operations), статусами (installed_apps),
шифрованием env-секретов (Fernet) и прогрессом по WebSocket.
Переиспуёт app/appstore/pipeline.py (poc_install / poc_teardown).
"""
from __future__ import annotations
import json
import re
import secrets as _secrets
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Optional
from loguru import logger
from apscheduler.triggers.interval import IntervalTrigger
from ..appstore.pipeline import (
APP_DIR, APP_DATA_DIR, InstallSpec, PocError, _NAME_RE, _node_ssh, _shq,
poc_install, poc_teardown, poc_update,
)
from ..appstore.golden_template import GoldenSpec, build_golden_template
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid, soft_delete_vm_instance
from ..api.settings import get_setting, set_setting
from ..config import settings
from ..db import SessionLocal
from ..ipam_service import IPAMService
from ..models import AppOperation, CatalogApp, InstalledApp, IPAMNetwork, ProxmoxServer
from ..websocket_manager import broadcast_task_update
_engine_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="appstore_engine")
class EngineError(RuntimeError):
"""Ошибка бизнес-валидации движка (человекочитаемая)."""
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ── Журнал операции + WS ──────────────────────────────────────────────────────
def _record_step(operation_id: int, user_id: int, step: str, progress: int) -> None:
"""Добавить шаг в журнал операции и разослать по WebSocket (своя сессия)."""
db = SessionLocal()
try:
op = db.get(AppOperation, operation_id)
if not op:
return
log = list(op.steps_log or [])
log.append({"step": step, "progress": progress, "ts": _utcnow().isoformat()})
op.steps_log = log
op.progress = progress
db.commit()
try:
broadcast_task_update(user_id, "appstore_operation_update", op.to_dict())
except Exception:
pass
finally:
db.close()
def _finish_op(operation_id: int, user_id: int, status: str, error: Optional[str] = None) -> None:
db = SessionLocal()
try:
op = db.get(AppOperation, operation_id)
if not op:
return
op.status = status
op.finished_at = _utcnow()
if error:
op.error_text = error[:2000]
if status == "completed":
op.progress = 100
db.commit()
try:
broadcast_task_update(user_id, "appstore_operation_update", op.to_dict())
except Exception:
pass
finally:
db.close()
# ── Генерация env (ТЗ 5.3 + 6.2) ──────────────────────────────────────────────
def _generate_random(length: int) -> str:
length = max(8, int(length or 32))
# hex → безопасно для .env (нет спецсимволов), длина = length
return _secrets.token_hex(length // 2 + 1)[:length]
def build_env(app: CatalogApp, form_answers: dict) -> tuple[dict, dict]:
"""Собрать .env: платформенные переменные + ответы формы + random-секреты.
Возвращает (env, secrets_shown) — secrets_shown показываются пользователю один раз.
"""
form_answers = form_answers or {}
env = {
"APP_PORT": str(app.port or 80),
"APP_DATA_DIR": APP_DATA_DIR,
"APP_PROTOCOL": "http",
"APP_EXPOSED": "false",
"APP_ID": app.app_id,
"TZ": settings.TZ,
"ROOT_FOLDER_HOST": APP_DATA_DIR,
}
secrets_shown: dict = {}
# Umbrel: рантайм генерирует APP_PASSWORD (app password) сам, а compose
# ссылается на неё для паролей/ключей (ENCRYPTION_KEY, JWT_SECRET и т.п.).
# Без неё docker compose подставит пустую строку и приложение упадёт или
# останется без пароля. Генерируем здесь, чтобы значение сохранилось в
# env_encrypted и показалось пользователю один раз.
if (app.source or "runtipi") == "umbrel":
env["APP_PASSWORD"] = _generate_random(32)
secrets_shown["APP_PASSWORD"] = env["APP_PASSWORD"]
for field in (app.form_fields or []):
ev = field.get("env_variable")
if not ev:
continue
ftype = (field.get("type") or "text").lower()
if ftype == "random":
val = _generate_random(field.get("min") or 32)
env[ev] = val
secrets_shown[ev] = val
continue
val = form_answers.get(ev)
if val is None or val == "":
if field.get("required"):
raise EngineError(f"Поле '{field.get('label') or ev}' обязательно")
val = field.get("default", "")
if ftype == "boolean":
val = "true" if str(val).lower() in ("1", "true", "yes", "on") else "false"
val = str(val)
env[ev] = val
if ftype == "password":
secrets_shown[ev] = val
return env, secrets_shown
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
# Ключ PanelSettings, куда сохраняется volid собранного золотого шаблона.
_GOLDEN_SETTING_KEY = "appstore_golden_template"
def _golden_key(server_id: int) -> str:
return f"{_GOLDEN_SETTING_KEY}:{server_id}"
def _resolve_golden_template(db, server_id: int, ostemplate: Optional[str] = None) -> Optional[str]:
"""Определить golden vztmpl для конкретного сервера.
Приоритет: явный override → сохранённый для ЭТОГО server_id → старый общий
ключ (обратная совместимость с настройкой до per-server хранения) →
env-дефолт. vztmpl — файл на storage конкретного Proxmox-сервера, поэтому
шаблон, собранный на сервере A, не существует на сервере B — нельзя просто
отдавать один и тот же сохранённый volid для всех серверов.
"""
return (
ostemplate
or get_setting(db, _golden_key(server_id))
or get_setting(db, _GOLDEN_SETTING_KEY)
or settings.APPSTORE_GOLDEN_TEMPLATE
)
def prepare_golden_template(db, user, *, server_id: int, node: Optional[str] = None,
template_storage: str = "local",
rootfs_storage: str = "local-lvm",
bridge: str = "vmbr0", force: bool = False) -> dict:
"""Запустить сборку золотого шаблона в фоне. Возвращает operation_id."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
op = _new_operation(db, None, "golden_template", user.id, server_id=server_id)
_engine_executor.submit(
_run_golden_template, op.id, user.id, server_id, node,
template_storage, rootfs_storage, bridge, force,
)
return {"operation_id": op.id}
def _run_golden_template(operation_id, user_id, server_id, node,
template_storage, rootfs_storage, bridge, force):
db = SessionLocal()
try:
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
spec = GoldenSpec(
server_id=server_id, node=node, template_storage=template_storage,
rootfs_storage=rootfs_storage, bridge=bridge, force=force,
)
result = build_golden_template(db, spec, on_step)
# Сохраняем volid ПОД ЭТИМ СЕРВЕРОМ — install() на других серверах не
# должен получить чужой (недоступный на их storage) шаблон.
try:
set_setting(db, _golden_key(server_id), result.volid,
description=f"App Store: золотой LXC-шаблон сервера #{server_id}")
except Exception as e:
logger.warning(f"[appstore-engine] save golden template setting: {e}")
_finish_op(operation_id, user_id, "completed")
try:
broadcast_task_update(user_id, "appstore_golden_template_done",
{"volid": result.volid, "node": result.node,
"status": result.status, "server_id": server_id})
except Exception:
pass
except Exception as e:
logger.error(f"[appstore-engine] golden template failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def golden_template_status(db, server_id: int) -> dict:
"""Текущий шаблон конкретного сервера (БД/env) + последняя операция сборки на нём."""
op = (
db.query(AppOperation)
.filter(AppOperation.type == "golden_template", AppOperation.server_id == server_id)
.order_by(AppOperation.started_at.desc())
.first()
)
return {
"configured_template": _resolve_golden_template(db, server_id),
"env_template": settings.APPSTORE_GOLDEN_TEMPLATE,
"saved_template": get_setting(db, _golden_key(server_id)),
"last_operation": op.to_dict() if op else None,
}
# ── Установка ─────────────────────────────────────────────────────────────────
def _allocate_ipam(db, *, network_id: int, pool_id: Optional[int], name: str,
server_id: int, vmid: int, node: Optional[str],
allocated_by: Optional[str]) -> tuple[str, str]:
"""Выделить IP из IPAM для приложения. Возвращает (ip_config, static_ip).
ip_config — строка для net0 ("ip=<addr>/<mask>,gw=<gw>"). Бросает EngineError
при отсутствии свободного адреса, чтобы ошибка сразу вернулась пользователю.
"""
ipam = IPAMService(db)
allocation, error = ipam.auto_allocate_ip(
network_id=network_id, pool_id=pool_id,
resource_type="ct", resource_name=name,
proxmox_server_id=server_id, proxmox_vmid=vmid, proxmox_node=node,
allocated_by=allocated_by, notes=f"App Store: {name}",
)
if error or not allocation:
raise EngineError(f"Не удалось выделить IP из IPAM: {error or 'нет свободных адресов'}")
ip = allocation.ip_address
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
mask = "24"
gateway = None
if net:
if net.network and "/" in net.network:
mask = net.network.split("/")[1]
gateway = net.gateway or None
ip_config = f"ip={ip}/{mask},gw={gateway}" if gateway else f"ip={ip}/{mask}"
return ip_config, ip
def install(db, user, *, app_id: str, name: str, form_answers: dict,
server_id: int, node: Optional[str] = None, cores: int = 2,
memory: int = 2048, disk: int = 8, storage: str = "local-lvm",
bridge: str = "vmbr0", ostemplate: Optional[str] = None,
port: Optional[int] = None,
ipam_network_id: Optional[int] = None,
ipam_pool_id: Optional[int] = None) -> dict:
"""Синхронная часть: валидация + запись в БД. Пайплайн — в пуле потоков."""
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app:
raise EngineError("Приложение не найдено в каталоге")
if not app.available:
raise EngineError("Приложение помечено недоступным")
if not app.compose_yaml:
raise EngineError("У приложения отсутствует docker-compose.yml")
if not _NAME_RE.match(name or ""):
raise EngineError("Недопустимое имя приложения (a-z, 0-9, дефис, 1..63)")
golden = _resolve_golden_template(db, server_id, ostemplate)
if not golden:
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
# Кастомный порт из формы имеет приоритет над портом каталога (app.port).
if port is not None and not (1 <= port <= 65535):
raise EngineError("Порт должен быть в диапазоне 1..65535")
effective_port = port if port else app.port
env, secrets_shown = build_env(app, form_answers)
# Учётные данные по умолчанию из манифеста (Umbrel) — показываем сразу
# после установки: у многих приложений это единственный способ узнать
# логин/пароль админа. deterministic → пароль = APP_PASSWORD (см. build_env).
creds = app.default_credentials or {}
if creds.get("username"):
secrets_shown["USERNAME"] = creds["username"]
default_pwd = env.get("APP_PASSWORD") if creds.get("deterministic") else creds.get("password")
if default_pwd:
secrets_shown["PASSWORD"] = default_pwd
# APP_PORT в окружении должен соответствовать выбранному порту.
if port:
env["APP_PORT"] = str(port)
vmid = get_next_vmid(db, server_id)
# Proxmox nextid не резервирует номер: пока предыдущая установка ещё не
# создала CT, nextid возвращает тот же VMID. Вторая установка тогда падает
# на create, а её retry (идемпотентность по vmid) «подхватывает» ЧУЖОЙ
# контейнер и заливает в него свой compose. Сдвигаемся мимо VMID, уже
# занятых записями installed_apps этого сервера.
taken = {
v for (v,) in db.query(InstalledApp.vmid)
.filter(InstalledApp.server_id == server_id, InstalledApp.vmid.isnot(None))
.all()
}
while vmid in taken:
vmid += 1
# IPAM (опционально): выделяем адрес до старта пайплайна, чтобы ошибка
# (нет свободных IP) вернулась пользователю синхронно.
ip_config = None
static_ip = None
if ipam_network_id:
ip_config, static_ip = _allocate_ipam(
db, network_id=ipam_network_id, pool_id=ipam_pool_id, name=name,
server_id=server_id, vmid=vmid, node=node,
allocated_by=getattr(user, "username", None),
)
ia = InstalledApp(
app_id=app_id, server_id=server_id, vmid=vmid, node=node, name=name,
version_installed=str(app.version) if app.version is not None else None,
tipi_version_installed=app.tipi_version,
env_encrypted=json.dumps(env), ip=static_ip, port=effective_port, status="installing",
owner_id=user.id,
# Параметры создания CT — для повтора установки: если контейнер так и
# не был создан, retry должен пересоздать его с ТЕМИ ЖЕ ресурсами,
# storage/bridge и статическим IP из IPAM, а не с дефолтами.
install_params={
"cores": cores, "memory": memory, "disk": disk,
"storage": storage, "bridge": bridge, "ip_config": ip_config,
"port_explicit": bool(port),
},
)
db.add(ia)
db.commit()
db.refresh(ia)
op = _new_operation(db, ia.id, "install", user.id)
_engine_executor.submit(
_run_install, ia.id, op.id, user.id, app.compose_yaml, env, golden,
cores, memory, disk, server_id, node, name, effective_port, ia.version_installed, vmid,
storage, bridge, ip_config, static_ip, app.source or "runtipi",
app.data_files or {}, bool(port),
)
return {"installed_app_id": ia.id, "operation_id": op.id, "vmid": vmid, "secrets": secrets_shown}
def _new_operation(db, installed_app_id: int, op_type: str, user_id: int,
server_id: Optional[int] = None) -> AppOperation:
op = AppOperation(
installed_app_id=installed_app_id, type=op_type, server_id=server_id,
status="running", progress=0, steps_log=[], user_id=user_id,
)
db.add(op)
db.commit()
db.refresh(op)
return op
def _run_install(installed_app_id, operation_id, user_id, compose_yaml, env, ostemplate,
cores, memory, disk, server_id, node, name, port, version, vmid,
storage, bridge, ip_config=None, static_ip=None, source="runtipi",
data_files=None, port_explicit=False):
"""Тело установки в пуле потоков."""
db = SessionLocal()
try:
spec = InstallSpec(
server_id=server_id, ostemplate=ostemplate, name=name,
compose_yaml=compose_yaml, port=port or 80, port_explicit=port_explicit,
env_vars=env,
node=node, vmid=vmid, cores=cores, memory=memory, disk=disk,
storage=storage, bridge=bridge, ip_config=ip_config, static_ip=static_ip,
source=source or "runtipi", data_files=data_files or {},
)
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
result = poc_install(db, spec, on_step)
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.vmid = result.vmid
ia.node = result.node
ia.ip = result.ip
ia.port = spec.port
ia.version_installed = version
ia.status = "running" if result.status == "running" else "error"
db.commit()
if result.status == "running":
_finish_op(operation_id, user_id, "completed")
else:
_finish_op(operation_id, user_id, "failed",
error="Health-check не прошёл (приложение не ответило по HTTP)")
except Exception as e:
try:
db.rollback()
except Exception:
pass
try:
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "error"
ia.vmid = vmid # сохранить vmid для идемпотентного повтора
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] install #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def retry_install(db, user, installed_app_id: int) -> dict:
"""Повтор установки (ТЗ 6.1): контейнер не пересоздаётся (проверка по vmid)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if ia.status not in ("error", "installing"):
raise EngineError(f"Повтор недоступен из статуса '{ia.status}'")
app = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
compose = app.compose_yaml if app else None
if not compose:
raise EngineError("compose приложения недоступен для повтора")
golden = _resolve_golden_template(db, ia.server_id)
if not golden:
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
env = ia.env_dict()
ia.status = "installing"
db.commit()
op = _new_operation(db, ia.id, "install", user.id)
# Повтор идемпотентен по vmid: существующий контейнер не пересоздаётся
# (тогда params/ip_config игнорируются pайплайном). Если же CT так и не был
# создан — пересоздаём с исходными параметрами установки (install_params),
# включая статический IP из IPAM; для старых записей без install_params
# остаются прежние дефолты. Известный IP (ia.ip) передаём как static_ip,
# чтобы не ждать выдачи DHCP впустую.
params = ia.install_params or {}
_engine_executor.submit(
_run_install, ia.id, op.id, user.id, compose, env, golden,
params.get("cores", 2), params.get("memory", 2048), params.get("disk", 8),
ia.server_id, ia.node, ia.name, ia.port, ia.version_installed, ia.vmid,
params.get("storage", "local-lvm"), params.get("bridge", "vmbr0"),
params.get("ip_config"), ia.ip, (app.source or "runtipi"),
(app.data_files or {}), bool(params.get("port_explicit")),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
# ── Удаление (нужно для обработки сбоя M2; полный lifecycle — M3) ──────────────
def delete_app(db, user, installed_app_id: int) -> dict:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
op = _new_operation(db, ia.id, "delete", user.id)
ia.status = "deleting"
db.commit()
_engine_executor.submit(_run_delete, ia.id, op.id, user.id, ia.server_id, ia.vmid, ia.node)
return {"operation_id": op.id}
def _run_delete(installed_app_id, operation_id, user_id, server_id, vmid, node):
db = SessionLocal()
try:
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
if vmid and server_id:
# Не глотаем ошибку teardown: если Proxmox не подтвердил реальное
# завершение удаления (см. poc_teardown/_TEARDOWN_WAIT_TIMEOUT),
# ресурсы ноды могут быть ещё заняты — пробрасываем наверх, чтобы
# НЕ помечать установку удалённой раньше времени (иначе следующая
# установка того же приложения словит гонку блокировки VMID).
poc_teardown(db, server_id, vmid, node, on_step)
# Освобождаем IP в IPAM (no-op, если приложение ставилось по DHCP).
try:
IPAMService(db).release_ip_by_vmid(
server_id, vmid, released_by="appstore",
reason=f"App Store: удаление приложения #{installed_app_id}")
except Exception as e:
logger.warning(f"[appstore-engine] IPAM release vmid={vmid}: {e}")
# Помечаем кэш-запись инстанса удалённой сразу, чтобы контейнер
# исчез из списка Инстансов немедленно, не дожидаясь фонового воркера.
try:
soft_delete_vm_instance(db, server_id, vmid)
except Exception as e:
logger.warning(f"[appstore-engine] soft-delete vm_instance vmid={vmid}: {e}")
# финальное событие ДО удаления записи (каскад удалит app_operations)
_finish_op(operation_id, user_id, "completed")
ia = db.get(InstalledApp, installed_app_id)
if ia:
db.delete(ia)
db.commit()
try:
broadcast_task_update(user_id, "appstore_app_deleted", {"installed_app_id": installed_app_id})
except Exception:
pass
except Exception as e:
logger.error(f"[appstore-engine] delete #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
# Оставляем InstalledApp на месте (ресурсы ноды не подтверждены
# освобождёнными) — статус "error" позволяет повторить удаление позже.
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "error"
db.commit()
finally:
db.close()
# ── Обновление и откат (M4) ───────────────────────────────────────────────────
_SNAP_PREFIX = "preupd"
def _snap_name(version: Optional[str], ts: datetime) -> str:
"""Имя снапшота pre-update: буквы/цифры/подчёркивание, ≤ 40, начинается с буквы."""
ver = re.sub(r"[^A-Za-z0-9]", "", str(version or "0"))[:12] or "0"
stamp = ts.strftime("%Y%m%d%H%M%S")
return f"{_SNAP_PREFIX}_{ver}_{stamp}"[:40]
def _prune_snapshots(client, node: str, vmid: int, keep: str) -> None:
"""Оставить только последний pre-update снапшот (ТЗ 6.3), старые удалить."""
try:
snaps = client.get_container_snapshots(node, vmid)
except Exception:
return
for s in snaps or []:
name = s.get("name")
if not name or name == "current" or name == keep:
continue
if name.startswith(_SNAP_PREFIX + "_"):
try:
res = client.delete_container_snapshot(node, vmid, name)
upid = res.get("upid") if isinstance(res, dict) else None
if upid:
client.wait_for_task(node, upid, timeout=120)
except Exception as e:
logger.warning(f"[appstore-engine] prune snapshot {name}: {e}")
def update(db, user, installed_app_id: int, form_answers: Optional[dict] = None) -> dict:
"""Обновление приложения: снапшот → pull → up → health (ТЗ 6.3)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if ia.status not in ("running", "stopped", "update_failed"):
raise EngineError(f"Обновление недоступно из статуса '{ia.status}'")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
app = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
if not app or not app.compose_yaml:
raise EngineError("Свежая версия приложения недоступна в каталоге")
op = _new_operation(db, ia.id, "update", user.id)
_engine_executor.submit(
_run_update, ia.id, op.id, user.id, app.compose_yaml,
str(app.version) if app.version is not None else None, app.tipi_version,
form_answers or {}, (app.source or "runtipi"), (app.data_files or {}),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
new_version, new_tipi, form_answers, source="runtipi",
data_files=None):
db = SessionLocal()
try:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
_finish_op(operation_id, user_id, "failed", error="Установка исчезла")
return
server, client = _client_for(db, ia.server_id)
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
# 1. Снапшот pre-update-<version>-<timestamp>
on_step("Создание снапшота перед обновлением...", 15)
snapname = _snap_name(ia.version_installed, _utcnow())
snap = client.create_container_snapshot(
ia.node, ia.vmid, snapname, description="pvemanager-appstore pre-update")
if not (isinstance(snap, dict) and snap.get("success")):
err = snap.get("error") if isinstance(snap, dict) else "snapshot failed"
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed", error=f"Снапшот не создан: {err}")
return
ia.last_snapshot = snapname
ia.snapshot_version = ia.version_installed
ia.snapshot_tipi_version = ia.tipi_version_installed
ia.status = "updating"
db.commit()
# 2. env: сохраняем прежний, доливаем новые ответы формы (ТЗ 6.3)
env = ia.env_dict()
for k, v in (form_answers or {}).items():
env[k] = str(v)
# 3-4. push нового compose → pull && up → health
status, eff_port = poc_update(
server, client, ia.vmid, ia.node, compose_yaml,
env, ia.port or 80, ia.ip, on_step,
source=source or "runtipi", data_files=data_files or {},
port_explicit=bool((ia.install_params or {}).get("port_explicit")),
)
if status == "running":
ia.status = "running"
ia.version_installed = new_version
ia.tipi_version_installed = new_tipi
ia.update_available = False
ia.env_encrypted = json.dumps(env)
# Нормализация compose могла опубликовать другой порт — фиксируем
# его в БД, иначе url/health-check будут указывать не туда.
if eff_port:
ia.port = eff_port
db.commit()
_prune_snapshots(client, ia.node, ia.vmid, keep=snapname) # хранить последний 1
_finish_op(operation_id, user_id, "completed")
else:
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed",
error="Health-check после обновления не прошёл — доступен откат")
except Exception as e:
try:
db.rollback()
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "update_failed"
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] update #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
def rollback(db, user, installed_app_id: int) -> dict:
"""Откат обновления восстановлением снапшота (ТЗ 6.4)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if not ia.last_snapshot:
raise EngineError("Нет pre-update снапшота для отката")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
op = _new_operation(db, ia.id, "rollback", user.id)
_engine_executor.submit(_run_rollback, ia.id, op.id, user.id)
return {"installed_app_id": ia.id, "operation_id": op.id}
def _run_rollback(installed_app_id, operation_id, user_id):
db = SessionLocal()
try:
ia = db.get(InstalledApp, installed_app_id)
if not ia:
_finish_op(operation_id, user_id, "failed", error="Установка исчезла")
return
server, client = _client_for(db, ia.server_id)
snapname = ia.last_snapshot
def on_step(step: str, progress: int) -> None:
_record_step(operation_id, user_id, step, progress)
on_step("Остановка контейнера...", 20)
try:
upid = client.stop_container(ia.node, ia.vmid)
if upid:
client.wait_for_task(ia.node, upid, timeout=60)
except Exception:
pass
on_step(f"Восстановление снапшота {snapname}...", 45)
res = client.rollback_container_snapshot(ia.node, ia.vmid, snapname, start=True)
upid = res.get("upid") if isinstance(res, dict) else None
if isinstance(res, dict) and not res.get("success"):
ia.status = "update_failed"
db.commit()
_finish_op(operation_id, user_id, "failed",
error=f"Откат не удался: {res.get('error')}")
return
if upid:
client.wait_for_task(ia.node, upid, timeout=300)
on_step("Запуск контейнера...", 70)
try:
client.start_container(ia.node, ia.vmid)
except Exception:
pass
on_step(f"Health-check http://{ia.ip}:{ia.port} ...", 88)
from ..appstore.pipeline import _health_check
ok = _health_check(ia.ip, ia.port or 80, 180) if ia.ip else False
# вернуть прежнюю версию в БД (ТЗ 6.4)
if ia.snapshot_version is not None:
ia.version_installed = ia.snapshot_version
ia.tipi_version_installed = ia.snapshot_tipi_version
ia.status = "running" if ok else "stopped"
db.commit()
_finish_op(operation_id, user_id, "completed" if ok else "failed",
error=None if ok else "Контейнер восстановлен, но health-check не прошёл")
except Exception as e:
try:
db.rollback()
ia = db.get(InstalledApp, installed_app_id)
if ia:
ia.status = "update_failed"
db.commit()
except Exception:
pass
logger.error(f"[appstore-engine] rollback #{installed_app_id} failed: {e}")
_finish_op(operation_id, user_id, "failed", error=str(e))
finally:
db.close()
# ── Управление жизненным циклом (M3): start / stop / restart / logs ───────────
def _client_for(db, server_id: int):
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise EngineError("Proxmox сервер не найден")
return server, _get_proxmox_client(server)
def lifecycle_action(db, user, installed_app_id: int, action: str) -> dict:
"""Start / stop / restart LXC приложения (ТЗ 6.5)."""
if action not in ("start", "stop", "restart"):
raise EngineError(f"Неизвестное действие: {action}")
ia = db.get(InstalledApp, installed_app_id)
if not ia:
raise EngineError("Установка не найдена")
if not ia.vmid or not ia.server_id or not ia.node:
raise EngineError("У приложения нет привязанного контейнера")
_server, client = _client_for(db, ia.server_id)
if action == "start":
upid = client.start_container(ia.node, ia.vmid)
new_status = "running"
elif action == "stop":
upid = client.stop_container(ia.node, ia.vmid)
new_status = "stopped"
else: # restart
upid = client.restart_container(ia.node, ia.vmid)
new_status = "running"
if upid:
try:
client.wait_for_task(ia.node, upid, timeout=60)
except Exception:
pass
ia.status = new_status
db.commit()
db.refresh(ia)
try:
broadcast_task_update(user.id, "appstore_operation_update",
{"kind": "appstore", "installed_app_id": ia.id,
"type": action, "status": "completed", "progress": 100})
except Exception:
pass
return ia.to_dict()
def get_logs(db, installed_app_id: int, tail: int = 200) -> str:
"""docker compose logs --tail N через pct exec (ТЗ 6.5, по запросу)."""
ia = db.get(InstalledApp, installed_app_id)
if not ia or not ia.vmid or not ia.server_id:
raise EngineError("У приложения нет привязанного контейнера")
server, client = _client_for(db, ia.server_id)
ssh = _node_ssh(server, client)
if not ssh.connect():
raise EngineError("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"
out, _code = ssh.execute(
f"pct exec {ia.vmid} -- /bin/bash -lc {_shq(inner)}", return_exit_code=True)
return out or ""
finally:
ssh.close()
# ── Реконсиляция состояния (ТЗ 6.6) ───────────────────────────────────────────
_RECONCILE_STATES = ("running", "stopped", "orphaned")
def reconcile(db) -> dict:
"""Сверить статус LXC в Proxmox с БД. Пропавший контейнер → 'orphaned'."""
apps = db.query(InstalledApp).filter(InstalledApp.status.in_(_RECONCILE_STATES)).all()
changed = 0
for ia in apps:
if not ia.vmid or not ia.server_id or not ia.node:
continue
try:
_server, client = _client_for(db, ia.server_id)
if not client.is_connected():
# Сервер целиком недоступен (сеть/креды) — это ничего не говорит
# о судьбе контейнера; не помечать все приложения orphaned.
continue
st = client.get_container_status(ia.node, ia.vmid)
except Exception:
continue
if not st:
new_status = "orphaned"
else:
s = (st.get("status") or "").lower()
new_status = "running" if s == "running" else "stopped" if s == "stopped" else ia.status
if new_status != ia.status:
ia.status = new_status
changed += 1
if changed:
db.commit()
return {"checked": len(apps), "changed": changed}
def _scheduled_reconcile() -> None:
db = SessionLocal()
try:
reconcile(db)
except Exception as e:
logger.warning(f"[appstore-engine] scheduled reconcile failed: {e}")
finally:
db.close()
def start_reconcile_scheduler():
"""Периодическая реконсиляция каждые 60с (ТЗ 6.6). Из lifespan."""
from .backup_scheduler import get_scheduler
aps = get_scheduler()
aps.add_job(
_scheduled_reconcile,
trigger=IntervalTrigger(seconds=60),
id="appstore_reconcile",
replace_existing=True,
misfire_grace_time=30,
)
if not aps.running:
aps.start()
logger.info("App Store reconcile scheduler registered (every 60s)")
return aps

View File

@@ -152,7 +152,9 @@ async def run_backup_job(job_id: int) -> None:
try:
result = client.create_backup(
node=job.node,
vmid=vmid if vmid != "all" else 0,
# "all" пробрасываем как есть: create_backup превратит его в
# параметр vzdump all=1 (vmid=0 PVE отвергает с ошибкой)
vmid=vmid,
storage=job.storage,
mode=job.mode,
compress=job.compress,
@@ -196,21 +198,13 @@ async def run_backup_job(job_id: int) -> None:
def _build_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model"""
if server.use_password:
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
)
return ProxmoxClient(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl,
)
"""Build a ProxmoxClient from a ProxmoxServer model.
Идём через from_server — он учитывает нестандартный порт (!= 8006) и
корректно выбирает token/password. Ручная сборка по ip_address ломала
бэкапы для серверов на кастомном порту.
"""
return ProxmoxClient.from_server(server)
def _update_job_status(db, job: BackupJob, status: str,

View File

@@ -3,9 +3,9 @@ Metrics Broadcaster — asyncio background tasks that push live metrics
to WebSocket channel subscribers.
Channels:
dashboard_metrics — all servers / VMs overview (10 s)
server_metrics:{server_id}_{node} — single Proxmox node status (5 s)
instance_metrics:{server_id}_{vmid}_{type}_{node} — VM / LXC status (5 s)
dashboard_metrics — all servers / VMs overview (2 s)
server_metrics:{server_id}_{node} — single Proxmox node status (2 s)
instance_metrics:{server_id}_{vmid}_{type}_{node} — VM / LXC status (1 s)
On-demand:
handle_rrd_request(ws, request_id, action, params, db_factory)
@@ -44,12 +44,35 @@ _DISK_CACHE_TTL = 30.0
# I/O rate tracking for instances-list channel: key = "server_id:vmid"
_list_io_prev: Dict[str, Tuple[float, Dict[str, float]]] = {}
# Last-good per-server dashboard payload: server_id → {vms, containers, nodes}.
# Used so a transient Proxmox timeout on one poll cycle does not blank the
# dashboard (readings would otherwise flash to zero and back every 2 s).
_server_last_good: Dict[int, dict] = {}
def get_cached_metrics(channel: str) -> Optional[Any]:
"""Return the last cached payload for a channel, or None."""
return _metrics_cache.get(channel)
def _merge_server_cache(server_id: int, base: dict) -> dict:
"""
Fill empty vms/containers/nodes from the last successful poll for this
server, and remember any non-empty values for future degraded cycles.
Keeps the dashboard stable when a single poll returns partial data.
"""
cached = _server_last_good.get(server_id, {})
for key in ("vms", "containers", "nodes"):
if base.get(key):
cached[key] = base[key]
elif cached.get(key):
base[key] = cached[key]
base["vms_count"] = len(base.get("vms") or [])
base["containers_count"] = len(base.get("containers") or [])
_server_last_good[server_id] = cached
return base
# ──────────────────────────────────────────────────────────────────────────────
# Synchronous helpers (run inside a thread via asyncio.to_thread)
# ──────────────────────────────────────────────────────────────────────────────
@@ -74,7 +97,6 @@ def _fetch_server_resources_with_nodes(server) -> dict:
Pull VMs + containers + per-node CPU/RAM/Disk status + cached RRD slices.
Runs in a thread pool — safe to block.
"""
from ..proxmox import get_proxmox_resources
from ..api.proxmox._helpers import _get_proxmox_client
base: dict = {
@@ -88,23 +110,18 @@ def _fetch_server_resources_with_nodes(server) -> dict:
"nodes": [],
}
# Клиент строим через from_server — учитывает нестандартный порт (!= 8006)
# и выбор token/password. Ручная сборка по ip_address ломала дашборд для
# серверов на кастомном порту.
try:
client = _get_proxmox_client(server)
except Exception as exc:
base["error"] = str(exc)
return _merge_server_cache(server.id, base)
# ── VMs + containers ──────────────────────────────────────────────────
try:
if server.use_password:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
)
else:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl,
)
resources = client.get_all_resources()
vms = resources.get("vms", [])
containers = resources.get("containers", [])
base["vms"] = vms
@@ -112,14 +129,14 @@ def _fetch_server_resources_with_nodes(server) -> dict:
base["vms_count"] = len(vms)
base["containers_count"] = len(containers)
except Exception as exc:
# VM/container fetch failed — fall through to node fetch and let the
# last-good cache backfill vms/containers at the end.
base["error"] = str(exc)
return base
# ── Node status + RRD mini-slices ─────────────────────────────────────
try:
client = _get_proxmox_client(server)
if not client.is_connected():
return base
return _merge_server_cache(server.id, base)
nodes_raw = client.get_nodes()
now = time.monotonic()
nodes_out = []
@@ -170,7 +187,7 @@ def _fetch_server_resources_with_nodes(server) -> dict:
base["nodes"] = nodes_out
except Exception as exc:
logger.debug(f"[metrics_broadcaster] node stats error for server {server.id}: {exc}")
return base
return _merge_server_cache(server.id, base)
def _fetch_node_status(db_factory, server_id: int, node: str):
@@ -262,7 +279,7 @@ def _fetch_rrd(db_factory, server_id: int, action: str, params: dict):
# ──────────────────────────────────────────────────────────────────────────────
# Dashboard metrics loop (push every 10 s)
# Dashboard metrics loop (push every 2 s)
# ──────────────────────────────────────────────────────────────────────────────
async def dashboard_metrics_loop(db_factory) -> None:
@@ -302,7 +319,7 @@ async def dashboard_metrics_loop(db_factory) -> None:
# ──────────────────────────────────────────────────────────────────────────────
# Server / node metrics loop (push every 5 s)
# Server / node metrics loop (push every 2 s)
# ──────────────────────────────────────────────────────────────────────────────
async def server_metrics_loop(db_factory) -> None:
@@ -433,8 +450,15 @@ def _enumerate_instances_for_list(db_factory):
db = db_factory()
try:
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).all()
# Build clients while the session is open (from_server reads server attrs)
server_clients = [(s.id, _get_proxmox_client(s)) for s in servers]
# Build clients while the session is open (from_server reads server attrs).
# Per-server guard: один сервер с битой конфигурацией не должен
# останавливать метрики для всех остальных.
server_clients = []
for s in servers:
try:
server_clients.append((s.id, _get_proxmox_client(s)))
except Exception as exc:
logger.debug(f"[metrics_broadcaster] skip server {s.id} ({s.name}): {exc}")
finally:
db.close()

View File

@@ -0,0 +1,217 @@
"""
Синхронизация каталога скриптов из git-репозитория (аналог appstore_catalog.py
sync_catalog(), но проще: bash-файлы с метаданными в заголовочных комментариях
вместо отдельных манифестов).
Конвенция метаданных (первые строки файла, до первой не-комментарной строки
после shebang):
#!/bin/bash
# name: Очистка старых логов
# description: Удаляет журналы старше 30 дней в /var/log
# target: node
# category: maintenance
# param: DAYS|Дней хранить|number|false|30
Файлы без `# name:` каталогизируются по имени файла.
"""
from __future__ import annotations
import glob as _glob
import os
import re
import shutil
import subprocess
import tempfile
from typing import Dict, List, Optional
from loguru import logger
from sqlalchemy.orm import Session
from ..config import utcnow
from ..models import ScriptCatalog, ScriptGitRepo
_META_RE = re.compile(r"^#\s*(name|description|target|category)\s*:\s*(.*)$", re.IGNORECASE)
_PARAM_RE = re.compile(r"^#\s*param\s*:\s*(.*)$", re.IGNORECASE)
_CS_APP_RE = re.compile(r'^APP="([^"]+)"')
_CS_VAR_RE = re.compile(r'^(var_\w+)="\$\{var_\w+:-([^}]*)\}"$')
def _parse_script_metadata(text: str, fallback_name: str) -> dict:
name = fallback_name
description = None
target = "guest"
category = None
params: List[dict] = []
for line in text.splitlines()[:60]:
stripped = line.strip()
if not stripped.startswith("#"):
if stripped and not stripped.startswith("#!"):
break
continue
m = _META_RE.match(stripped)
if m:
key, val = m.group(1).lower(), m.group(2).strip()
if key == "name":
name = val
elif key == "description":
description = val
elif key == "target":
target = val if val in ("node", "guest") else "guest"
elif key == "category":
category = val
continue
pm = _PARAM_RE.match(stripped)
if pm:
parts = [p.strip() for p in pm.group(1).split("|")]
# name|label|type|required|default
p_name = parts[0] if len(parts) > 0 else None
if not p_name:
continue
params.append({
"name": p_name,
"label": parts[1] if len(parts) > 1 and parts[1] else p_name,
"type": parts[2] if len(parts) > 2 and parts[2] else "string",
"required": (parts[3].lower() == "true") if len(parts) > 3 else False,
"default": parts[4] if len(parts) > 4 else None,
})
return {
"name": name,
"description": description,
"target_type": target,
"category": category,
"params_schema": params,
}
def _parse_community_scripts_metadata(text: str, fallback_name: str) -> dict:
"""Метаданные ct/*.sh из community-scripts/ProxmoxVE: APP="..." + var_X="${var_X:-default}".
В самих ct-скриптах нет полей name/description в привычном виде: имя
приложения берётся из `APP=`, а параметры — из строк `var_X="${var_X:-DEF}"`
(тот самый bash-паттерн "переменная окружения с дефолтом", который upstream
читает при headless-запуске). Эти var_X переносятся в params_schema один в
один по имени, чтобы существующий execute-движок (export VAR=value перед
телом скрипта) сработал без изменений.
"""
name = fallback_name
params: List[dict] = []
for line in text.splitlines():
stripped = line.strip()
m = _CS_APP_RE.match(stripped)
if m:
name = m.group(1)
continue
pm = _CS_VAR_RE.match(stripped)
if pm:
p_name, default = pm.group(1), pm.group(2)
label = p_name.removeprefix("var_").replace("_", " ").capitalize()
params.append({
"name": p_name,
"label": label,
"type": "number" if default.lstrip("-").isdigit() else "string",
"required": False,
"default": default,
})
return {
"name": name,
"description": f"Community-Scripts LXC installer: {name}",
"target_type": "node",
"category": "LXC (community-scripts)",
"params_schema": params,
}
def _clone_repo(repo: ScriptGitRepo, dest: str) -> None:
subprocess.run(
["git", "clone", "--depth", "1", "--branch", repo.branch, repo.url, dest],
check=True, capture_output=True, timeout=120, text=True,
)
def sync_git_repo(db: Session, repo: ScriptGitRepo) -> dict:
"""Синхронизировать один git-источник. Идемпотентно (upsert по slug)."""
created = updated = skipped = errors = disappeared = 0
tmp_dir = tempfile.mkdtemp(prefix="pve_script_repo_")
try:
try:
_clone_repo(repo, tmp_dir)
except subprocess.CalledProcessError as e:
err = (e.stderr or str(e))[:1000]
repo.last_sync_error = err
db.commit()
return {"error": err, "created": 0, "updated": 0, "skipped": 0, "errors": 1}
except subprocess.TimeoutExpired:
repo.last_sync_error = "git clone timeout (>120s)"
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)
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"
seen_slugs: List[str] = []
for path in files:
rel_path = os.path.relpath(path, tmp_dir)
slug = f"git-{repo.id}-{rel_path}".replace("/", "-").replace("\\", "-")
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except Exception as e:
errors += 1
logger.warning(f"[script-sync] read {rel_path} failed: {e}")
continue
if is_community_scripts:
meta = _parse_community_scripts_metadata(content, fallback_name=os.path.basename(rel_path))
else:
meta = _parse_script_metadata(content, fallback_name=os.path.basename(rel_path))
existing = db.query(ScriptCatalog).filter(ScriptCatalog.slug == slug).first()
if existing:
existing.name = meta["name"]
existing.description = meta["description"]
existing.target_type = meta["target_type"]
existing.category = meta["category"]
existing.params_schema = meta["params_schema"]
existing.content = content
existing.git_path = rel_path
existing.source = record_source
updated += 1
else:
db.add(ScriptCatalog(
slug=slug, name=meta["name"], description=meta["description"],
target_type=meta["target_type"], category=meta["category"],
params_schema=meta["params_schema"], content=content,
source=record_source, git_repo_id=repo.id, git_path=rel_path,
))
created += 1
seen_slugs.append(slug)
db.commit()
# Удалить из этого источника то, что исчезло из репозитория.
stale = db.query(ScriptCatalog).filter(
ScriptCatalog.git_repo_id == repo.id,
~ScriptCatalog.slug.in_(seen_slugs) if seen_slugs else True,
).all()
for s in stale:
db.delete(s)
disappeared += 1
db.commit()
repo.last_synced_at = utcnow()
repo.last_sync_error = None
db.commit()
return {"created": created, "updated": updated, "skipped": skipped,
"errors": errors, "disappeared": disappeared}
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)

View File

@@ -0,0 +1,195 @@
"""
Движок выполнения скриптов из каталога (backend/app/models/scripts.py).
Переиспользует существующую инфраструктуру:
- SSHClient (root@node) — выполнение на ноде и `pct exec` для LXC (тот же
паттерн, что и backend/app/appstore/pipeline.py: base64 -> файл -> exec,
без конкатенации пользовательского ввода в shell-команду).
- ProxmoxClient.execute_script() — выполнение на VM через QEMU guest agent
(backend/app/proxmox/client.py), уже используется в
POST /api/{server_id}/vm/{vmid}/execute-script.
Безопасность параметров: значения параметров НЕ подставляются в тело скрипта
текстово. Вместо этого перед скриптом добавляется блок `export VAR='...'` с
экранированием одинарных кавычек, скрипт читает их как обычные переменные
окружения — тот же принцип, что и .env в appstore pipeline.
"""
from __future__ import annotations
import base64
import time
from dataclasses import dataclass
from typing import Dict, Optional
from loguru import logger
from ..models import ProxmoxServer
from ..proxmox import ProxmoxClient
from ..ssh_client import SSHClient
_CMD_TIMEOUT = 60
# Community-скрипты (build.func и т.п.) вызывают `clear` и печатают цветной
# вывод, которым нужен установленный TERM. Панель запускает скрипт без PTY
# (get_pty=False), поэтому TERM не наследуется — и `clear` падает с
# "TERM environment variable not set", обрывая весь скрипт. Задаём безопасный
# дефолт префиксом команды (надёжнее env paramiko: sshd AcceptEnv его режет).
_SCRIPT_ENV = "TERM=xterm"
class ScriptExecError(RuntimeError):
"""Ошибка выполнения скрипта с человекочитаемым сообщением."""
@dataclass
class ExecResult:
success: bool
output: str
exit_code: int
error: Optional[str] = None
def _shell_quote(value: str) -> str:
"""Обернуть значение в одинарные кавычки, безопасно для shell."""
return "'" + str(value).replace("'", "'\\''") + "'"
def render_params(content: str, params: Dict[str, str]) -> str:
"""Добавить блок export-переменных перед телом скрипта.
Скрипт обращается к параметрам как к обычным переменным окружения
($NAME), значения никогда не конкатенируются в исполняемые команды.
"""
if not params:
return content
lines = ["#!/bin/sh", "# --- параметры, подставленные каталогом скриптов ---"]
for name, value in params.items():
# Имя переменной ограничено тем, что уже провалидировано схемой
# (см. api/scripts.py) — здесь только защитное экранирование значения.
lines.append(f"export {name}={_shell_quote('' if value is None else value)}")
lines.append("# --- конец параметров ---")
return "\n".join(lines) + "\n" + content
def _node_ssh(server: ProxmoxServer, client: ProxmoxClient) -> SSHClient:
ssh_host = (server.ip_address or server.hostname or "").split(":")[0]
return SSHClient(
hostname=ssh_host,
username="root",
password=getattr(client, "_password", None),
timeout=30,
)
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
output, exit_code = ssh.execute(cmd, return_exit_code=True, timeout=timeout)
if exit_code == -1:
raise ScriptExecError(f"{what}: таймаут (>{timeout}с) или обрыв SSH")
if exit_code != 0:
raise ScriptExecError(f"{what}: exit={exit_code}: {(output or '').strip()[:2000]}")
return output or ""
def run_on_node(server: ProxmoxServer, client: ProxmoxClient, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт на хосте Proxmox (root@node)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=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())}"
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}",
return_exit_code=True, timeout=timeout)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
finally:
try:
ssh.execute(f"rm -f {tmp}", timeout=10)
except Exception:
pass
def run_on_lxc(server: ProxmoxServer, client: ProxmoxClient, node: str, vmid: int,
script: str, interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри LXC через `pct exec` (SSH ноды, нет guest agent для LXC)."""
ssh = _node_ssh(server, client)
if not ssh.connect():
return ExecResult(success=False, output="", exit_code=-1,
error=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"
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")
output, exit_code = ssh.execute(
f"pct exec {vmid} -- env {_SCRIPT_ENV} {interpreter} {tmp_ct}",
return_exit_code=True, timeout=timeout,
)
return ExecResult(success=exit_code == 0, output=output or "", exit_code=exit_code)
except ScriptExecError as e:
return ExecResult(success=False, output="", exit_code=-1, error=str(e))
finally:
try:
ssh.execute(f"rm -f {tmp_node}", timeout=10)
ssh.execute(f"pct exec {vmid} -- rm -f {tmp_ct}", timeout=10)
except Exception:
pass
def run_on_qemu(client: ProxmoxClient, node: str, vmid: int, script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Выполнить скрипт внутри VM через QEMU guest agent."""
result = client.execute_script(node, vmid, script, interpreter=interpreter, timeout=timeout)
if not result.get("success"):
return ExecResult(
success=False, output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", -1),
error=result.get("error") or result.get("stderr"),
)
return ExecResult(
success=result.get("exit_code", 0) == 0,
output=result.get("stdout", "") or "",
exit_code=result.get("exit_code", 0),
error=result.get("stderr") or None,
)
def execute(server: ProxmoxServer, *, target_type: str, node: Optional[str],
vmid: Optional[int], vm_type: Optional[str], script: str,
interpreter: str, timeout: int) -> ExecResult:
"""Точка входа: выполнить скрипт на нужной цели.
target_type: "node" (хост Proxmox) | "guest" (VM/LXC).
vm_type (только для guest): "qemu" | "lxc".
"""
try:
client = ProxmoxClient.from_server(server)
except Exception as e:
return ExecResult(success=False, output="", exit_code=-1,
error=f"Не удалось создать Proxmox-клиент: {e}")
try:
if target_type == "node":
return run_on_node(server, client, script, interpreter, timeout)
if target_type == "guest":
if vmid is None or not node:
return ExecResult(success=False, output="", exit_code=-1,
error="Для guest-скрипта нужны node и vmid")
if vm_type == "lxc":
return run_on_lxc(server, client, node, vmid, script, interpreter, timeout)
return run_on_qemu(client, node, vmid, script, interpreter, timeout)
return ExecResult(success=False, output="", exit_code=-1,
error=f"Неизвестный target_type: {target_type}")
except Exception as e:
logger.error(f"[script_engine] execute failed: {e}")
return ExecResult(success=False, output="", exit_code=-1, error=str(e))

View File

@@ -40,7 +40,7 @@ class TaskQueueService:
"""Service for managing and processing bulk operation tasks"""
# Valid task types
TASK_TYPES = ['bulk_start', 'bulk_stop', 'bulk_restart', 'bulk_delete', 'bulk_shutdown']
TASK_TYPES = ['bulk_start', 'bulk_stop', 'bulk_restart', 'bulk_delete', 'bulk_shutdown', 'bulk_migrate']
@staticmethod
def create_task(
@@ -164,12 +164,14 @@ class TaskQueueProcessor:
return None
def _execute_vm_action(
self,
client: ProxmoxClient,
action: str,
vmid: int,
vm_type: str,
node: str
self,
client: ProxmoxClient,
action: str,
vmid: int,
vm_type: str,
node: str,
target_node: Optional[str] = None,
online: bool = False,
) -> tuple[bool, str]:
"""Execute action on VM/container"""
try:
@@ -181,10 +183,14 @@ class TaskQueueProcessor:
elif action == 'restart':
client.restart_container(node, vmid)
elif action == 'shutdown':
# Shutdown = graceful stop
client.stop_container(node, vmid)
# Graceful shutdown через ACPI/init внутри контейнера
client.shutdown_container(node, vmid)
elif action == 'delete':
client.delete_container(node, vmid)
elif action == 'migrate':
if not target_node:
return False, "target_node is required"
client.migrate_container(node, vmid, target_node, online=online)
else:
return False, f"Unknown action: {action}"
else: # qemu
@@ -195,13 +201,17 @@ class TaskQueueProcessor:
elif action == 'restart':
client.restart_vm(node, vmid)
elif action == 'shutdown':
# Shutdown = graceful stop
client.stop_vm(node, vmid)
# Graceful shutdown через ACPI — гостевая ОС завершается штатно
client.shutdown_vm(node, vmid)
elif action == 'delete':
client.delete_vm(node, vmid)
elif action == 'migrate':
if not target_node:
return False, "target_node is required"
client.migrate_vm(node, vmid, target_node, online=online)
else:
return False, f"Unknown action: {action}"
return True, "OK"
except Exception as e:
return False, str(e)
@@ -229,6 +239,7 @@ class TaskQueueProcessor:
'bulk_restart': 'restart',
'bulk_shutdown': 'shutdown',
'bulk_delete': 'delete',
'bulk_migrate': 'migrate',
}
action = action_map.get(task.task_type)
@@ -248,7 +259,9 @@ class TaskQueueProcessor:
vm_type = item.get('vm_type', 'qemu')
node = item.get('node', '')
name = item.get('name', f'{vm_type.upper()}-{vmid}')
target_node = item.get('target_node')
online = bool(item.get('online'))
client = self._get_proxmox_client(db, server_id)
if not client:
results.append({
@@ -260,7 +273,8 @@ class TaskQueueProcessor:
})
task.failed_items += 1
else:
success, message = self._execute_vm_action(client, action, vmid, vm_type, node)
success, message = self._execute_vm_action(
client, action, vmid, vm_type, node, target_node, online)
results.append({
'server_id': server_id,
'vmid': vmid,
@@ -332,6 +346,44 @@ class TaskQueueProcessor:
pass
def recover_interrupted_tasks() -> int:
"""Пометить задачи, зависшие в статусе 'running' после рестарта бэкенда.
process_pending_tasks() пропускает обработку, пока существует running-задача,
поэтому без этой очистки одна прерванная рестартом задача навсегда блокирует
всю очередь bulk-операций. Вызывается один раз при старте приложения —
в этот момент никакой задачи выполняться не может.
Returns:
Количество восстановленных (помеченных failed) задач.
"""
db = SessionLocal()
try:
stuck = db.query(TaskQueue).filter(TaskQueue.status == 'running').all()
for task in stuck:
task.status = 'failed'
task.error_message = 'Interrupted by backend restart'
task.completed_at = utcnow()
if stuck:
db.commit()
for task in stuck:
_ws_broadcast(task, "task_update")
logger.warning(
f"[TASK QUEUE] Task #{task.id} ({task.task_type}) was interrupted "
f"by restart — marked as failed"
)
return len(stuck)
except Exception as e:
logger.error(f"[TASK QUEUE] Failed to recover interrupted tasks: {e}")
try:
db.rollback()
except Exception:
pass
return 0
finally:
db.close()
# Global processor instance
task_processor = TaskQueueProcessor()

View File

@@ -0,0 +1,85 @@
"""Two-Factor Authentication (TOTP) service.
Self-service, panel-side 2FA independent of Proxmox. Uses time-based one-time
passwords (RFC 6238) compatible with Google Authenticator, Authy, 1Password,
etc. Backup (recovery) codes are single-use and stored only as SHA-256 hashes.
"""
import hashlib
import io
import secrets
from typing import List
import pyotp
import qrcode
import qrcode.image.svg
# Number of one-time recovery codes generated when 2FA is enabled.
BACKUP_CODES_COUNT = 10
# Accept codes from the adjacent time step to tolerate clock drift (~±30s).
TOTP_VALID_WINDOW = 1
def generate_secret() -> str:
"""Generate a new base32 TOTP secret."""
return pyotp.random_base32()
def build_otpauth_uri(secret: str, username: str, issuer: str = "PVEmanager") -> str:
"""Build an otpauth:// provisioning URI for authenticator apps."""
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."""
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)
except Exception:
return False
def qr_svg(uri: str) -> str:
"""Render the provisioning URI as an inline SVG string (no Pillow needed)."""
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=10, border=2)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue().decode("utf-8")
def generate_backup_codes(count: int = BACKUP_CODES_COUNT) -> List[str]:
"""Generate human-friendly single-use recovery codes (format: xxxx-xxxx)."""
codes = []
for _ in range(count):
raw = secrets.token_hex(4) # 8 hex chars
codes.append(f"{raw[:4]}-{raw[4:]}")
return codes
def hash_backup_code(code: str) -> str:
"""SHA-256 hash of a normalized backup code (case/format-insensitive)."""
normalized = code.strip().lower().replace("-", "").replace(" ", "")
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def hash_backup_codes(codes: List[str]) -> List[str]:
return [hash_backup_code(c) for c in codes]
def consume_backup_code(code: str, stored_hashes: List[str]) -> bool:
"""Check a backup code against stored hashes; remove it in-place if matched.
Returns True and mutates ``stored_hashes`` (removing the used hash) on a
match, otherwise returns False and leaves the list unchanged.
"""
if not code or not stored_hashes:
return False
h = hash_backup_code(code)
if h in stored_hashes:
stored_hashes.remove(h)
return True
return False

View File

@@ -26,6 +26,13 @@ GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
# Отключить проверку обновлений (для приватных репозиториев без токена)
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
# Максимальное время «идёт обновление» (сек). Успешное обновление перезапускает
# сам контейнер app, обнуляя in-memory статус, поэтому если процесс живёт с
# is_updating=True дольше этого срока — хостовый watchdog не смог пересобрать/
# перезапустить панель (OOM при сборке, ребут хоста, сервис лёг). По истечении
# статус переводится в терминальный "failed", чтобы UI не висел на 80% вечно.
UPDATE_TIMEOUT_SECONDS = int(os.environ.get('UPDATE_TIMEOUT_SECONDS', '900'))
# Статус обновления (in-memory)
update_status = {
"is_updating": False,
@@ -353,7 +360,41 @@ async def check_for_updates() -> Dict[str, Any]:
def get_update_status() -> Dict[str, Any]:
"""Получить текущий статус обновления"""
"""Получить текущий статус обновления.
Самовосстановление: если обновление «идёт» дольше UPDATE_TIMEOUT_SECONDS,
значит контейнер так и не был перезапущен (успешное обновление перезапускает
процесс и обнуляет статус). Переводим в терминальный "failed" с понятной
ошибкой — иначе UI застревает на «restarting — 80%» навсегда.
"""
global update_status
if update_status["is_updating"] and update_status.get("started_at"):
try:
started = datetime.fromisoformat(update_status["started_at"])
elapsed = (datetime.now() - started).total_seconds()
if elapsed > UPDATE_TIMEOUT_SECONDS:
update_status = {
"is_updating": False,
"started_at": update_status["started_at"],
"stage": "failed",
"progress": update_status.get("progress", 0),
"error": (
f"Update timed out after {UPDATE_TIMEOUT_SECONDS // 60} min: "
"the panel was not rebuilt/restarted. The host update service "
"(pvemanager-update.service) likely failed — check "
"logs/update_host.log on the host (a common cause is the build "
"running out of memory when no swap is configured)."
),
"completed": False,
}
logger.warning(
"Update marked as failed: exceeded %ss timeout without restart",
UPDATE_TIMEOUT_SECONDS,
)
except (ValueError, TypeError) as e:
logger.warning(f"Could not parse update started_at: {e}")
return update_status.copy()

View File

@@ -153,20 +153,47 @@ class SSHClient:
return False
def execute(self, command: str, return_exit_code: bool = False) -> Union[Optional[str], tuple]:
"""Выполнить команду на сервере"""
def execute(self, command: str, return_exit_code: bool = False,
timeout: Optional[int] = None) -> Union[Optional[str], tuple]:
"""Выполнить команду на сервере.
timeout — предельное время ожидания завершения команды (сек). Если не
задан, используется self.timeout. Команды, легально идущие долго
(docker compose pull/up), должны передавать увеличенный timeout.
"""
if not self._connected or not self.client:
if not self.connect():
return (None, -1) if return_exit_code else None
cmd_timeout = timeout if timeout is not None else self.timeout
try:
stdin, stdout, stderr = self.client.exec_command(
command, timeout=self.timeout, get_pty=False
command, timeout=cmd_timeout, get_pty=False
)
# Wait for command to complete
exit_code = stdout.channel.recv_exit_status()
# ВАЖНО: recv_exit_status() ИГНОРИРУЕТ таймаут exec_command и блокирует
# поток навсегда, если удалённая команда подвисла (типичная ловушка
# paramiko — из-за неё установка приложения замирала на «pct push»).
# Поэтому ждём готовности статуса с жёстким дедлайном.
channel = stdout.channel
deadline = time.time() + cmd_timeout
while not channel.exit_status_ready():
if time.time() > deadline:
logger.error(
f"Command exit-status timeout on {self.hostname} "
f"(>{cmd_timeout}s): {command}"
)
try:
channel.close()
except Exception:
pass
self._connected = False
self._cleanup_connection()
return (None, -1) if return_exit_code else None
time.sleep(0.2)
exit_code = channel.recv_exit_status()
output = stdout.read().decode('utf-8', errors='ignore').strip()
error_output = stderr.read().decode('utf-8', errors='ignore').strip()

View File

@@ -90,14 +90,30 @@ def run_async(coro):
return asyncio.run(coro)
class ProbeFailure(Exception):
"""Неудачная проверка доступности сервера с указанием причины.
error_kind: 'auth' — API ответил 401, 'connection' — не отвечает.
"""
def __init__(self, message: str, error_kind: str = None):
super().__init__(message)
self.error_kind = error_kind or 'connection'
class MonitoringWorker:
"""Background worker for monitoring and generating notifications"""
# Сколько подряд неудачных опросов статуса UPID терпим, прежде чем пометить
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
UPID_FAIL_THRESHOLD = 3
def __init__(self):
self.last_vm_states: Dict[str, str] = {} # vm_id -> status
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
self.last_server_states: Dict[int, bool] = {} # server_id -> is_online
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
"""Create ProxmoxClient from server model, raising if auth is missing."""
@@ -107,20 +123,22 @@ class MonitoringWorker:
raise ValueError(f"Server {server.name} has no valid authentication configured")
return ProxmoxClient.from_server(server)
def _notify_server_offline(self, db, server: ProxmoxServer, error: str, users: List[User]):
def _notify_server_offline(self, db, server: ProxmoxServer, error: str, users: List[User],
error_kind: str = None):
"""Send notification that server went offline"""
server_id = server.id
# Check cooldown (don't spam alerts)
last_alert = self.last_server_alerts.get(server_id)
if last_alert:
# 10 minute cooldown between alerts for same server
if (datetime.now().timestamp() - last_alert) < 600:
return
# Update server status in DB
server.is_online = False
server.last_error = error
server.last_error_kind = error_kind or 'connection'
server.last_check = utcnow()
db.commit()
@@ -242,39 +260,42 @@ class MonitoringWorker:
for server in servers:
try:
client = self._create_proxmox_client(server)
# Quick connectivity check - just test if API responds
if client.is_connected():
ok, error_kind, check_error = client.check_connection()
if ok:
# Server is online
was_offline = self.last_server_states.get(server.id) == False
# Update server status in DB
if not server.is_online or was_offline:
server.is_online = True
server.last_error = None
server.last_error_kind = None
server.last_check = utcnow()
db.commit()
# Notify immediately if server recovered
if was_offline:
self._notify_server_recovered(db, server, users)
self.last_server_states[server.id] = True
else:
raise ConnectionError("API not responding")
raise ProbeFailure(check_error or "API not responding", error_kind)
except ValueError as e:
# Configuration error - log but don't mark as offline
logger.warning(f"[SERVER CHECK] Server {server.name} configuration error: {e}")
except Exception as e:
# Server is offline
error_msg = str(e)
was_online = self.last_server_states.get(server.id, True)
# Update server status in DB
server.is_online = False
server.last_error = error_msg[:500]
server.last_error_kind = getattr(e, 'error_kind', None) or 'connection'
server.last_check = utcnow()
db.commit()
@@ -293,7 +314,8 @@ class MonitoringWorker:
self.last_server_alerts[server.id] = now
if was_online:
logger.warning(f"[SERVER CHECK] Server {server.name} went OFFLINE: {error_msg}")
reason = 'AUTH FAILED' if server.last_error_kind == 'auth' else 'OFFLINE'
logger.warning(f"[SERVER CHECK] Server {server.name} went {reason}: {error_msg}")
else:
logger.warning(f"[SERVER CHECK] Server {server.name} still OFFLINE (repeat notification)")
@@ -400,8 +422,12 @@ class MonitoringWorker:
client = self._create_proxmox_client(server)
# Explicitly test connection - this will return False if offline
if not client.is_connected():
raise ConnectionError(f"Cannot connect to Proxmox server at {server.hostname}")
ok, error_kind, check_error = client.check_connection()
if not ok:
raise ProbeFailure(
check_error or f"Cannot connect to Proxmox server at {server.hostname}",
error_kind,
)
# Server is online - notify if it was offline before
self._notify_server_online(db, server, users)
@@ -452,7 +478,8 @@ class MonitoringWorker:
# Connection error - server is offline
error_msg = str(e)
logger.error(f"[MONITORING] Server {server.name} connection failed: {error_msg}")
self._notify_server_offline(db, server, error_msg, users)
self._notify_server_offline(db, server, error_msg, users,
error_kind=getattr(e, 'error_kind', None))
except Exception as e:
logger.error(f"[MONITORING] Critical error in VM status monitoring: {e}", exc_info=True)
@@ -964,20 +991,43 @@ class MonitoringWorker:
continue
# --- status -----------------------------------------------
proxmox_status = client.get_task_status(task.node, task.upid)
# If status is empty, it means Proxmox API rejected the UPID
# Транзитный сбой (сеть, перезагрузка ноды) не должен
# помечать живую задачу как failed: считаем ПОДРЯД идущие
# неудачи и финализируем только после UPID_FAIL_THRESHOLD.
try:
proxmox_status = client.get_task_status(task.node, task.upid, raise_on_error=True)
fetch_error = None
except Exception as fe:
proxmox_status = None
fetch_error = str(fe)
if not proxmox_status:
logger.warning(f"[UPID SYNC] Cannot fetch status for task #{task.id} (UPID={task.upid[:30]}...) — marking as failed")
fails = self.upid_fail_counts.get(task.id, 0) + 1
self.upid_fail_counts[task.id] = fails
if fails < self.UPID_FAIL_THRESHOLD:
logger.debug(
f"[UPID SYNC] Task #{task.id}: status fetch failed "
f"({fails}/{self.UPID_FAIL_THRESHOLD}), will retry: {fetch_error}"
)
continue
logger.warning(
f"[UPID SYNC] Cannot fetch status for task #{task.id} "
f"(UPID={task.upid[:30]}...) after {fails} attempts — marking as failed: {fetch_error}"
)
task.status = "failed"
task.exit_status = "Invalid UPID"
task.exit_status = (fetch_error or "Invalid UPID")[:200]
task.completed_at = utcnow()
db.commit()
self.upid_fail_counts.pop(task.id, None)
try:
broadcast_task_update(task.user_id, "task_update", task.to_dict())
except Exception:
pass
continue
# Успешный опрос — сбрасываем счётчик неудач
self.upid_fail_counts.pop(task.id, None)
pve_status = proxmox_status.get("status", "running")
# --- log --------------------------------------------------
@@ -1190,7 +1240,15 @@ def start_monitoring_worker():
# Task queue processing - every 5 seconds
try:
from app.services.task_queue_service import process_task_queue
from app.services.task_queue_service import process_task_queue, recover_interrupted_tasks
# Восстановление задач, прерванных рестартом: без этого зависшая
# 'running'-задача навсегда блокирует обработку очереди.
try:
recovered = recover_interrupted_tasks()
if recovered:
logger.info(f"Recovered {recovered} interrupted bulk task(s) after restart")
except Exception as e:
logger.warning(f"Interrupted task recovery failed: {e}")
scheduler.add_job(
process_task_queue,
trigger=IntervalTrigger(seconds=5),

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Массовое назначение владельца (owner_id) инстансам vm_instances.
Инстансы, синхронизированные из Proxmox, создаются без владельца
(owner_id = NULL) и потому не видны обычным пользователям на странице
«Инстансы» (и, после закрытия утечек, на страницах нод/дашборде).
Этот скрипт назначает существующим инстансам конкретного пользователя.
Примеры (внутри контейнера app):
# Все активные инстансы -> пользователю testuser
python assign_instances_owner.py --user testuser --all
# Только инстансы сервера 1
python assign_instances_owner.py --user testuser --server 1
# Конкретные VMID (на всех серверах или в связке с --server)
python assign_instances_owner.py --user testuser --vmids 100,101,102
# Снять владельца (сделать ничейными)
python assign_instances_owner.py --clear --all
# Предпросмотр без записи
python assign_instances_owner.py --user testuser --all --dry-run
Запуск через docker compose:
docker compose exec app python assign_instances_owner.py --user testuser --all
"""
import argparse
import sys
sys.path.insert(0, '/app')
from app.db import get_db
from app.models import User, VMInstance
def main() -> int:
parser = argparse.ArgumentParser(description="Назначить владельца инстансам")
target = parser.add_mutually_exclusive_group(required=True)
target.add_argument("--user", help="Username, которому назначить инстансы")
target.add_argument("--clear", action="store_true",
help="Снять владельца (owner_id = NULL)")
scope = parser.add_mutually_exclusive_group(required=True)
scope.add_argument("--all", action="store_true",
help="Все активные инстансы")
scope.add_argument("--vmids", help="Список VMID через запятую (напр. 100,101)")
parser.add_argument("--server", type=int, default=None,
help="Ограничить конкретным Proxmox server_id")
parser.add_argument("--include-deleted", action="store_true",
help="Также затронуть soft-deleted записи")
parser.add_argument("--dry-run", action="store_true",
help="Показать, что будет изменено, без записи")
args = parser.parse_args()
db = next(get_db())
try:
owner_id = None
if args.user:
user = db.query(User).filter(User.username == args.user).first()
if not user:
print(f"❌ Пользователь '{args.user}' не найден")
return 1
owner_id = user.id
query = db.query(VMInstance)
if not args.include_deleted:
query = query.filter(VMInstance.deleted_at.is_(None))
if args.server is not None:
query = query.filter(VMInstance.server_id == args.server)
if args.vmids:
try:
vmids = [int(v.strip()) for v in args.vmids.split(",") if v.strip()]
except ValueError:
print("❌ --vmids должен быть списком чисел через запятую")
return 1
query = query.filter(VMInstance.vmid.in_(vmids))
instances = query.order_by(VMInstance.server_id, VMInstance.vmid).all()
if not instances:
print("⚠️ Под указанные условия не попал ни один инстанс")
return 0
action = "снятие владельца" if args.clear else f"назначение владельца -> {args.user} (id={owner_id})"
print(f"Найдено инстансов: {len(instances)}. Действие: {action}"
f"{' [dry-run]' if args.dry_run else ''}")
for inst in instances:
print(f" server={inst.server_id} vmid={inst.vmid} "
f"name={inst.name!r} owner_id: {inst.owner_id} -> "
f"{'NULL' if args.clear else owner_id}")
if args.dry_run:
print("Ничего не записано (--dry-run).")
return 0
for inst in instances:
inst.owner_id = owner_id
db.commit()
print(f"✅ Обновлено записей: {len(instances)}")
return 0
except Exception as e:
db.rollback()
print(f"❌ Ошибка: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1328,6 +1328,12 @@ def migrate_deploy_tasks_kind(conn):
logger.info("✓ deploy_tasks.kind column added")
def migrate_deploy_tasks_target_server(conn):
"""Migration 36: Add 'target_server_id' column to deploy_tasks (remote-migrate destination cluster)"""
add_column_if_not_exists(conn, 'deploy_tasks', 'target_server_id', 'INTEGER')
logger.info("✓ deploy_tasks.target_server_id column added")
def migrate_user_ssh_keys_table(conn):
"""Migration 24: Create user_ssh_keys table for SSH key library per user."""
if table_exists(conn, 'user_ssh_keys'):
@@ -1468,6 +1474,429 @@ def migrate_granular_permissions(conn):
logger.info("✓ Granular permissions migration completed")
def migrate_two_factor(conn):
"""Migration 30: Two-factor authentication columns on users.
Ensures the TOTP columns exist (older DBs predating init.sql) and adds the
``two_factor_backup_codes`` JSON column for single-use recovery codes.
"""
added = False
if add_column_if_not_exists(conn, 'users', 'two_factor_enabled', "BOOLEAN DEFAULT FALSE NOT NULL"):
added = True
if add_column_if_not_exists(conn, 'users', 'two_factor_secret', "VARCHAR(100)"):
added = True
if add_column_if_not_exists(conn, 'users', 'two_factor_backup_codes', "JSONB"):
added = True
if added:
logger.info("✓ Added two-factor authentication columns to users")
else:
logger.info("✓ users two-factor columns already exist")
def migrate_appstore_catalog(conn):
"""Create catalog_apps table for the App Store module (M1)."""
if table_exists(conn, 'catalog_apps'):
logger.info("Table catalog_apps already exists, skipping")
return
conn.execute(text("""
CREATE TABLE catalog_apps (
app_id VARCHAR(100) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
version VARCHAR(50),
tipi_version INTEGER,
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
short_desc TEXT,
description_md TEXT,
logo_path VARCHAR(300),
port INTEGER,
form_fields JSONB NOT NULL DEFAULT '[]'::jsonb,
compose_yaml TEXT,
architectures JSONB NOT NULL DEFAULT '[]'::jsonb,
available BOOLEAN NOT NULL DEFAULT TRUE,
unavailable_reason VARCHAR(200),
deprecated BOOLEAN NOT NULL DEFAULT FALSE,
dynamic_config BOOLEAN NOT NULL DEFAULT FALSE,
source_url VARCHAR(500),
author VARCHAR(200),
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_catalog_apps_available ON catalog_apps (available)"))
conn.execute(text("CREATE INDEX idx_catalog_apps_name ON catalog_apps (name)"))
logger.info("✓ App Store catalog_apps table created")
def migrate_appstore_catalog_source(conn):
"""Add source column to catalog_apps (мультиисточник: runtipi | umbrel)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'source'):
return
conn.execute(text(
"ALTER TABLE catalog_apps ADD COLUMN source VARCHAR(50) NOT NULL DEFAULT 'runtipi'"
))
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_catalog_apps_source ON catalog_apps (source)"
))
logger.info("✓ App Store catalog_apps.source column added")
def migrate_appstore_catalog_data_files(conn):
"""Add data_files column to catalog_apps (файлы приложения для bind-mount'ов)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'data_files'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN data_files JSONB"))
logger.info("✓ App Store catalog_apps.data_files column added")
def migrate_appstore_default_credentials(conn):
"""Add default_credentials column to catalog_apps (учётные данные из манифеста)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'default_credentials'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN default_credentials JSONB"))
logger.info("✓ App Store catalog_apps.default_credentials column added")
def migrate_server_error_kind(conn):
"""Add last_error_kind column to proxmox_servers ('auth' | 'connection')."""
if not table_exists(conn, 'proxmox_servers'):
return
if add_column_if_not_exists(conn, 'proxmox_servers', 'last_error_kind', 'VARCHAR(20)'):
logger.info("✓ proxmox_servers.last_error_kind column added")
def pick_backfill_owner(candidates):
"""Pick the owner for one instance from creation evidence, or None.
``candidates`` is an iterable of ``(server_id, user_id)`` pairs already
filtered to the instance's VMID. A ``server_id`` of ``None`` means the
source did not record one (deploy_tasks does not set it for LXC), so the
pair is treated as applicable to any server.
Ownership grants access, so a guess is worse than leaving the row alone:
the owner is returned only when every applicable pair names the same user.
"""
users = {user_id for _, user_id in candidates if user_id is not None}
return users.pop() if len(users) == 1 else None
def migrate_backfill_instance_owner(conn):
"""
Backfill vm_instances.owner_id for instances that were created through the
panel but ended up ownerless.
Until save_vm_instance() learned to set the owner when updating an existing
row, every deploy lost it: the cache sync registers a VM with
owner_id = NULL within ~10s of it appearing on Proxmox — while the clone is
still running — so the deploy's own save minutes later always took the
update path, which ignored owner_id.
The creator is recovered from two sources, in order of confidence:
1. deploy_tasks — user_id is a real foreign key stored when the deploy was
queued (server_id is absent for LXC deploys, so it may be NULL)
2. audit_logs — 'vm_create' / 'ct_create' / 'lxc_create' entries, which
only carry a username and so are matched by name
A row is updated only when the evidence names exactly one existing user.
Ambiguous and unmatched instances keep owner_id = NULL for an operator to
assign deliberately with backend/assign_instances_owner.py. Instances that
already have an owner are never touched, which also makes the migration
idempotent.
Caveat: this recovers who *created* the instance, not necessarily who it was
meant for. A VM an admin created on behalf of a user is attributed to the
admin, because before the fix the intended owner was never persisted
anywhere. Those need the CLI script to be corrected.
"""
if not table_exists(conn, 'vm_instances'):
return
if not column_exists(conn, 'vm_instances', 'owner_id'):
logger.info("vm_instances.owner_id missing, skipping owner backfill")
return
orphans = conn.execute(text(
"SELECT id, server_id, vmid, name FROM vm_instances "
"WHERE owner_id IS NULL AND deleted_at IS NULL"
)).fetchall()
if not orphans:
logger.info("✓ No ownerless instances to backfill")
return
logger.info(f"Migration 41: backfilling owner for {len(orphans)} ownerless instance(s)...")
known_user_ids = {row[0] for row in conn.execute(text("SELECT id FROM users"))}
user_id_by_name = {
row[1]: row[0]
for row in conn.execute(text("SELECT id, username FROM users"))
if row[1]
}
# vmid -> [(server_id, user_id)], strongest source first
from_deploys: dict = {}
from_logs: dict = {}
if table_exists(conn, 'deploy_tasks'):
for user_id, server_id, vmid in conn.execute(text(
"SELECT user_id, server_id, vmid FROM deploy_tasks WHERE vmid IS NOT NULL"
)):
if user_id in known_user_ids:
from_deploys.setdefault(vmid, []).append((server_id, user_id))
if table_exists(conn, 'audit_logs'):
for username, server_id, resource_id, success in conn.execute(text(
"SELECT username, server_id, resource_id, success FROM audit_logs "
"WHERE action IN ('vm_create', 'ct_create', 'lxc_create', 'container_create') "
"AND username IS NOT NULL AND resource_id IS NOT NULL"
)):
if not success:
continue
user_id = user_id_by_name.get(username)
if user_id is None:
continue
try:
vmid = int(resource_id)
except (TypeError, ValueError):
continue
from_logs.setdefault(vmid, []).append((server_id, user_id))
assigned = 0
ambiguous = 0
unmatched = 0
for inst_id, server_id, vmid, name in orphans:
owner_id = None
had_evidence = False
for source in (from_deploys, from_logs):
applicable = [
(sid, uid) for sid, uid in source.get(vmid, [])
if sid is None or sid == server_id
]
if not applicable:
continue
had_evidence = True
owner_id = pick_backfill_owner(applicable)
if owner_id is not None:
break
if owner_id is None:
# Evidence that names several users (a reused VMID, most likely) is
# not usable, but it is worth distinguishing from no evidence at all.
if had_evidence:
ambiguous += 1
else:
unmatched += 1
continue
conn.execute(
text("UPDATE vm_instances SET owner_id = :owner_id "
"WHERE id = :id AND owner_id IS NULL"),
{"owner_id": owner_id, "id": inst_id},
)
assigned += 1
logger.info(f" VM {vmid} ({name}) -> user_id={owner_id}")
logger.info(
f"✓ Instance owner backfill: {assigned} assigned, "
f"{ambiguous} ambiguous, {unmatched} without evidence"
)
if ambiguous or unmatched:
logger.info(
" Remaining instances stay ownerless on purpose — assign them with "
"backend/assign_instances_owner.py"
)
def migrate_script_repo_metadata_format(conn):
"""Add metadata_format column to script_git_repos (header-comment | community-scripts-ct)."""
if not table_exists(conn, 'script_git_repos'):
return
if add_column_if_not_exists(conn, 'script_git_repos', 'metadata_format',
"VARCHAR(30) NOT NULL DEFAULT 'header-comment'"):
logger.info("✓ Added metadata_format column to script_git_repos")
def migrate_appstore_installed(conn):
"""Create installed_apps and app_operations tables for the App Engine (M2)."""
if not table_exists(conn, 'installed_apps'):
conn.execute(text("""
CREATE TABLE installed_apps (
id SERIAL PRIMARY KEY,
app_id VARCHAR(100),
server_id INTEGER,
vmid INTEGER,
node VARCHAR(100),
name VARCHAR(200) NOT NULL,
version_installed VARCHAR(50),
tipi_version_installed INTEGER,
env_encrypted TEXT,
ip VARCHAR(64),
port INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'installing',
update_available BOOLEAN NOT NULL DEFAULT FALSE,
last_snapshot VARCHAR(200),
owner_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_installed_apps_status ON installed_apps (status)"))
conn.execute(text("CREATE INDEX idx_installed_apps_owner ON installed_apps (owner_id)"))
conn.execute(text("CREATE INDEX idx_installed_apps_server_vmid ON installed_apps (server_id, vmid)"))
logger.info("✓ App Store installed_apps table created")
if not table_exists(conn, 'app_operations'):
conn.execute(text("""
CREATE TABLE app_operations (
id SERIAL PRIMARY KEY,
installed_app_id INTEGER REFERENCES installed_apps(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
progress INTEGER NOT NULL DEFAULT 0,
steps_log JSONB NOT NULL DEFAULT '[]'::jsonb,
error_text TEXT,
user_id INTEGER,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
finished_at TIMESTAMP WITH TIME ZONE
)
"""))
conn.execute(text("CREATE INDEX idx_app_operations_installed ON app_operations (installed_app_id)"))
logger.info("✓ App Store app_operations table created")
# M4: версия, зафиксированная в pre-update снапшоте (для отката версии в БД)
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_version', 'VARCHAR(50)')
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_tipi_version', 'INTEGER')
# Параметры создания CT для корректного повтора установки (retry)
add_column_if_not_exists(conn, 'installed_apps', 'install_params', 'JSONB')
def migrate_ipam_network_workspace(conn):
"""Migration 33: Bind IPAM networks to a workspace + default-network flag.
Adds ipam_networks.workspace_id (FK workspaces) and ipam_networks.is_default.
Backfills workspace_id from the network's proxmox_server_id via workspace_servers
(each server belongs to a workspace), so existing server-bound networks land in
the right workspace automatically.
"""
if not table_exists(conn, 'ipam_networks'):
logger.info("Table ipam_networks does not exist, skipping workspace migration")
return
added = False
if add_column_if_not_exists(conn, 'ipam_networks', 'workspace_id', 'INTEGER REFERENCES workspaces(id) ON DELETE SET NULL'):
added = True
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ipam_network_workspace ON ipam_networks(workspace_id)"
))
if add_column_if_not_exists(conn, 'ipam_networks', 'is_default', 'BOOLEAN NOT NULL DEFAULT FALSE'):
added = True
# Backfill workspace_id from server binding (only for rows not yet assigned).
if table_exists(conn, 'workspace_servers'):
try:
conn.execute(text("""
UPDATE ipam_networks n
SET workspace_id = ws.workspace_id
FROM workspace_servers ws
WHERE n.workspace_id IS NULL
AND n.proxmox_server_id IS NOT NULL
AND ws.server_id = n.proxmox_server_id
"""))
except Exception as e:
logger.debug(f"IPAM workspace backfill: {e}")
if added:
logger.info("✓ Added workspace_id/is_default to ipam_networks")
else:
logger.info("✓ ipam_networks.workspace_id/is_default already exist")
def migrate_app_operations_server_id(conn):
"""Migration 37: app_operations.server_id (per-server golden-template tracking).
Нужен только для операций без installed_app_id (golden_template) — чтобы
прогресс/история сборки золотого шаблона различались по серверам Proxmox,
а не показывали последнюю сборку глобально независимо от того, для
какого сервера она запускалась.
"""
if not table_exists(conn, 'app_operations'):
logger.info("Table app_operations does not exist, skipping server_id migration")
return
if add_column_if_not_exists(
conn, 'app_operations', 'server_id',
'INTEGER REFERENCES proxmox_servers(id) ON DELETE CASCADE'
):
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_app_operations_server ON app_operations(server_id)"
))
logger.info("✓ Added app_operations.server_id")
else:
logger.info("✓ app_operations.server_id already exists")
def migrate_setting_view_panel_only(conn):
"""Migration 42: drop setting:view where it was only granting the profile page.
`setting:view` used to gate the whole /settings page, which mixed panel-wide
configuration with a user's own account (password, 2FA, SSH keys,
notification preferences). The account half now lives at /profile and needs
no permission, so roles that were given setting:view just to let their users
reach their own settings would silently gain the panel settings page.
Only roles that clearly were not meant to administer the panel are touched:
those holding setting:view without setting:update or setting:manage. Runs
once — a marker in panel_settings keeps a later admin grant from being
reverted on the next startup.
"""
if not table_exists(conn, 'roles') or not table_exists(conn, 'panel_settings'):
logger.info("Tables roles/panel_settings missing, skipping setting:view split")
return
marker = 'migration_setting_view_panel_only'
done = conn.execute(
text("SELECT 1 FROM panel_settings WHERE key = :k"), {"k": marker}
).fetchone()
if done:
logger.info("✓ setting:view split already applied, skipping")
return
import json as _json
rows = conn.execute(text("SELECT id, name, permissions FROM roles")).fetchall()
changed = []
for role_id, role_name, permissions in rows:
perms = permissions if isinstance(permissions, dict) else _json.loads(permissions or '{}')
if not perms.get('setting:view'):
continue
if perms.get('setting:update') or perms.get('setting:manage'):
continue # a real panel administrator — leave the grant alone
del perms['setting:view']
conn.execute(
text("UPDATE roles SET permissions = :p WHERE id = :id"),
{"p": _json.dumps(perms), "id": role_id},
)
changed.append(role_name)
conn.execute(
text("""
INSERT INTO panel_settings (key, value, description)
VALUES (:k, 'done', 'setting:view no longer gates a user''s own profile page')
ON CONFLICT (key) DO NOTHING
"""),
{"k": marker},
)
if changed:
logger.info(f"✓ Removed panel-only setting:view from role(s): {', '.join(changed)}")
else:
logger.info("✓ No roles needed the setting:view split")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1714,6 +2143,110 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Granular permissions migration: {e}")
conn.rollback()
# Migration 30: Two-factor authentication columns
try:
migrate_two_factor(conn)
conn.commit()
except Exception as e:
logger.warning(f"Two-factor migration: {e}")
conn.rollback()
# Migration 31: App Store catalog
try:
migrate_appstore_catalog(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog migration: {e}")
conn.rollback()
# Migration 32: App Store installed apps + operations
try:
migrate_appstore_installed(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store installed migration: {e}")
conn.rollback()
# Migration 33: IPAM network workspace binding + default flag
try:
migrate_ipam_network_workspace(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM network workspace migration: {e}")
conn.rollback()
# Migration 34: App Store catalog source column (runtipi | umbrel)
try:
migrate_appstore_catalog_source(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog source migration: {e}")
conn.rollback()
# Migration 35: script_git_repos metadata_format column (header-comment | community-scripts-ct)
try:
migrate_script_repo_metadata_format(conn)
conn.commit()
except Exception as e:
logger.warning(f"Script repo metadata_format migration: {e}")
conn.rollback()
# Migration 36: deploy_tasks.target_server_id column (remote-migrate destination cluster)
try:
migrate_deploy_tasks_target_server(conn)
conn.commit()
except Exception as e:
logger.warning(f"Deploy tasks target_server_id migration: {e}")
conn.rollback()
# Migration 37: app_operations.server_id column (per-server golden template)
try:
migrate_app_operations_server_id(conn)
conn.commit()
except Exception as e:
logger.warning(f"App operations server_id migration: {e}")
conn.rollback()
# Migration 38: catalog_apps.data_files column (файлы приложения из репозитория)
try:
migrate_appstore_catalog_data_files(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog data_files migration: {e}")
conn.rollback()
# Migration 39: catalog_apps.default_credentials column (учётные данные из манифеста)
try:
migrate_appstore_default_credentials(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store default_credentials migration: {e}")
conn.rollback()
# Migration 40: proxmox_servers.last_error_kind ('auth' | 'connection')
try:
migrate_server_error_kind(conn)
conn.commit()
except Exception as e:
logger.warning(f"Server last_error_kind migration: {e}")
conn.rollback()
# Migration 41: backfill vm_instances.owner_id for ownerless instances
try:
migrate_backfill_instance_owner(conn)
conn.commit()
except Exception as e:
logger.warning(f"Instance owner backfill migration: {e}")
conn.rollback()
# Migration 42: setting:view now covers panel settings only
try:
migrate_setting_view_panel_only(conn)
conn.commit()
except Exception as e:
logger.warning(f"setting:view split migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

View File

@@ -10,6 +10,7 @@ paramiko==3.4.1
psutil==6.1.0
proxmoxer==2.1.0
requests==2.32.3
requests-toolbelt==1.0.0
aiofiles==24.1.0
python-dotenv==1.0.1
httpx==0.27.0
@@ -18,7 +19,10 @@ cryptography>=41.0.0
bcrypt==3.2.2
passlib[bcrypt]==1.7.4
pyjwt==2.9.0
pyotp==2.9.0
qrcode==7.4.2
email-validator==2.2.0
websockets==12.0
apscheduler==3.10.4
alembic==1.13.2
PyYAML==6.0.2

View File

@@ -60,16 +60,16 @@ class TestAPIServers:
db_session.add(workspace)
db_session.commit()
# Link the assigned server and the standard user to the workspace
# Link the assigned server and the standard user to the workspace.
# Note: no direct assigned_servers link — workspace membership alone
# must be enough to grant access (so the user can create instances).
ws_server = WorkspaceServer(workspace_id=workspace.id, server_id=server_assigned.id)
ws_user = WorkspaceUser(workspace_id=workspace.id, user_id=seed_users["user"].id)
db_session.add_all([ws_server, ws_user])
# Directly assign the server to the user as well
seed_users["user"].assigned_servers.append(server_assigned)
db_session.commit()
# Now standard user should see only the assigned server in their workspace
# Now standard user should see the workspace server without any direct
# assignment, but not the unassigned server outside their workspaces.
response2 = client.get("/proxmox/api/servers", headers=user_headers)
assert response2.status_code == 200
servers = response2.json()

View File

@@ -0,0 +1,185 @@
"""Tests for instance ownership and SSH key assignment on deploy.
Covers two regressions:
* ``save_vm_instance`` used to drop ``owner_id`` whenever a row for the
(server, vmid) pair already existed. Since ``sync_vm_cache`` runs every 10s
and registers VMs it sees on Proxmox with ``owner_id=NULL``, a deploy that
takes longer than one sync tick (i.e. every real deploy) always landed in
that branch and produced ownerless instances.
* SSH keys were resolved against the *requester's* profile, so a VM created by
an admin on behalf of a user received the admin's key instead of the owner's.
"""
from __future__ import annotations
import pytest
from app.api.proxmox._helpers import save_vm_instance
from app.api.ssh_keys import build_deploy_ssh_keys
from app.models import ProxmoxServer, VMInstance
KEY_ADMIN = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIadmin admin@panel"
KEY_OWNER = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIowner owner@laptop"
KEY_PASTED = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIpasted pasted@manual"
@pytest.fixture
def server(db_session):
srv = ProxmoxServer(
name="pve-test",
hostname="pve-test.local",
ip_address="10.0.0.1",
port=8006,
api_user="root@pam",
password="secret",
use_password=True,
verify_ssl=False,
)
db_session.add(srv)
db_session.commit()
return srv
def _sync_worker_registers_vm(db_session, server_id: int, vmid: int) -> VMInstance:
"""Mimic sync_vm_cache picking the VM up mid-deploy (no owner known)."""
row = VMInstance(
server_id=server_id, vmid=vmid, node="pve1",
vm_type="qemu", name="web-01", status="running",
)
db_session.add(row)
db_session.commit()
return row
# ── Ownership ───────────────────────────────────────────────────────────────
def test_owner_assigned_when_sync_registered_vm_first(db_session, server, seed_users):
"""The deploy must claim the row the background sync already created."""
owner = seed_users["user"]
_sync_worker_registers_vm(db_session, server.id, 100)
save_vm_instance(
db=db_session, server_id=server.id, vmid=100, node="pve1",
vm_type="qemu", name="web-01", owner_id=owner.id,
)
row = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=100).one()
assert row.owner_id == owner.id
def test_owner_assigned_when_no_prior_row(db_session, server, seed_users):
owner = seed_users["user"]
save_vm_instance(
db=db_session, server_id=server.id, vmid=101, node="pve1",
vm_type="qemu", name="web-02", owner_id=owner.id,
)
row = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=101).one()
assert row.owner_id == owner.id
def test_owner_preserved_when_caller_omits_owner(db_session, server, seed_users):
"""Callers that don't know the owner (e.g. save-before-delete) must not wipe it."""
owner = seed_users["user"]
save_vm_instance(
db=db_session, server_id=server.id, vmid=102, node="pve1",
vm_type="qemu", name="web-03", owner_id=owner.id,
)
save_vm_instance(
db=db_session, server_id=server.id, vmid=102, node="pve1",
vm_type="qemu", name="web-03",
)
row = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=102).one()
assert row.owner_id == owner.id
def test_owner_reassigned_on_restore_of_soft_deleted_row(db_session, server, seed_users):
admin, owner = seed_users["admin"], seed_users["user"]
save_vm_instance(
db=db_session, server_id=server.id, vmid=103, node="pve1",
vm_type="qemu", name="web-04", owner_id=admin.id,
)
row = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=103).one()
from sqlalchemy import func
row.deleted_at = func.now()
db_session.commit()
save_vm_instance(
db=db_session, server_id=server.id, vmid=103, node="pve1",
vm_type="qemu", name="web-04", owner_id=owner.id,
)
row = db_session.query(VMInstance).filter_by(server_id=server.id, vmid=103).one()
assert row.deleted_at is None
assert row.owner_id == owner.id
# ── SSH keys ────────────────────────────────────────────────────────────────
def test_admin_deploy_uses_owner_profile_key_not_own(db_session, seed_users):
admin, owner = seed_users["admin"], seed_users["user"]
admin.ssh_public_key = KEY_ADMIN
owner.ssh_public_key = KEY_OWNER
db_session.commit()
keys = build_deploy_ssh_keys(db_session, admin, owner.id)
assert KEY_OWNER in keys
assert KEY_ADMIN not in keys
def test_self_deploy_uses_own_profile_key(db_session, seed_users):
user = seed_users["user"]
user.ssh_public_key = KEY_OWNER
db_session.commit()
keys = build_deploy_ssh_keys(db_session, user, user.id)
assert keys.strip() == KEY_OWNER
def test_pasted_keys_and_profile_key_are_merged_and_deduped(db_session, seed_users):
user = seed_users["user"]
user.ssh_public_key = KEY_OWNER
db_session.commit()
keys = build_deploy_ssh_keys(
db_session, user, user.id, raw_keys=f"{KEY_PASTED}\n{KEY_OWNER}"
)
assert keys.splitlines() == [KEY_PASTED, KEY_OWNER]
def test_saved_library_keys_are_included(db_session, seed_users):
from app.models import UserSSHKey
user = seed_users["user"]
saved = UserSSHKey(user_id=user.id, name="laptop", public_key=KEY_PASTED)
db_session.add(saved)
db_session.commit()
keys = build_deploy_ssh_keys(db_session, user, user.id, key_ids=[saved.id])
assert KEY_PASTED in keys
def test_user_cannot_use_another_users_saved_key(db_session, seed_users):
from fastapi import HTTPException
from app.models import UserSSHKey
admin, user = seed_users["admin"], seed_users["user"]
admin_key = UserSSHKey(user_id=admin.id, name="admin-key", public_key=KEY_ADMIN)
db_session.add(admin_key)
db_session.commit()
with pytest.raises(HTTPException) as exc:
build_deploy_ssh_keys(db_session, user, user.id, key_ids=[admin_key.id])
assert exc.value.status_code == 403
def test_no_keys_anywhere_yields_empty_string(db_session, seed_users):
assert build_deploy_ssh_keys(db_session, seed_users["user"], seed_users["user"].id) == ""

View File

@@ -0,0 +1,285 @@
"""Tests for the ownerless-instance backfill (migration 41).
``table_exists`` / ``column_exists`` query ``information_schema`` and are
therefore PostgreSQL-only; they are stubbed so the migration itself — whose
statements are portable — can run against the SQLite test database.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from migrations import migrations as mig
from migrations.migrations import migrate_backfill_instance_owner, pick_backfill_owner
from app.models import AuditLog, DeployTask, ProxmoxServer, VMInstance
@pytest.fixture(autouse=True)
def postgres_only_checks(monkeypatch):
monkeypatch.setattr(mig, "table_exists", lambda conn, table: True)
monkeypatch.setattr(mig, "column_exists", lambda conn, table, column: True)
def _run(db_session):
"""Run the migration on a connection sharing the session's transaction.
The connection is fetched here rather than in a fixture because committing
seed data returns the previous one to the pool and closes it.
"""
migrate_backfill_instance_owner(db_session.connection())
def _server(db_session, name="pve-1"):
srv = ProxmoxServer(
name=name, hostname=f"{name}.local", ip_address="10.0.0.1", port=8006,
api_user="root@pam", password="secret", use_password=True, verify_ssl=False,
)
db_session.add(srv)
db_session.commit()
return srv
def _instance(db_session, server_id, vmid, owner_id=None, deleted_at=None):
row = VMInstance(
server_id=server_id, vmid=vmid, node="pve1", vm_type="qemu",
name=f"vm-{vmid}", status="running", owner_id=owner_id, deleted_at=deleted_at,
)
db_session.add(row)
db_session.commit()
return row
def _deploy_task(db_session, user_id, vmid, server_id):
db_session.add(DeployTask(
status="completed", step="done", progress=100, name=f"vm-{vmid}",
user_id=user_id, vmid=vmid, server_id=server_id,
))
db_session.commit()
def _create_log(db_session, username, vmid, server_id, action="vm_create", success=True):
db_session.add(AuditLog(
level="info", category="proxmox", action=action,
message=f"VM {vmid} created by {username}",
username=username, resource_type="vm", resource_id=str(vmid),
server_id=server_id, success=success,
))
db_session.commit()
def _owner_of(db_session, instance_id):
db_session.expire_all()
return db_session.query(VMInstance).get(instance_id).owner_id
# ── pick_backfill_owner ─────────────────────────────────────────────────────
def test_pick_returns_the_single_named_user():
assert pick_backfill_owner([(1, 7), (1, 7), (None, 7)]) == 7
def test_pick_refuses_to_guess_between_users():
assert pick_backfill_owner([(1, 7), (1, 9)]) is None
def test_pick_handles_no_candidates():
assert pick_backfill_owner([]) is None
# ── Backfill from deploy_tasks ──────────────────────────────────────────────
def test_owner_recovered_from_deploy_task(db_session, seed_users):
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 100)
_deploy_task(db_session, user.id, vmid=100, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_deploy_task_without_server_id_still_matches(db_session, seed_users):
"""LXC deploys queue a task without server_id — it must not be discarded."""
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 101)
_deploy_task(db_session, user.id, vmid=101, server_id=None)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_deploy_task_for_a_different_server_is_ignored(db_session, seed_users):
srv_a = _server(db_session, "pve-a")
srv_b = _server(db_session, "pve-b")
inst = _instance(db_session, srv_a.id, 102)
_deploy_task(db_session, seed_users["user"].id, vmid=102, server_id=srv_b.id)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
# ── Backfill from audit_logs ────────────────────────────────────────────────
def test_owner_recovered_from_audit_log(db_session, seed_users):
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 110)
_create_log(db_session, user.username, vmid=110, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
@pytest.mark.parametrize("action", ["ct_create", "lxc_create", "container_create"])
def test_container_create_actions_are_recognised(db_session, seed_users, action):
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 111)
_create_log(db_session, user.username, vmid=111, server_id=srv.id, action=action)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_failed_create_is_not_evidence(db_session, seed_users):
srv = _server(db_session)
inst = _instance(db_session, srv.id, 112)
_create_log(db_session, seed_users["user"].username, vmid=112,
server_id=srv.id, success=False)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
def test_log_from_a_deleted_user_is_ignored(db_session, seed_users):
srv = _server(db_session)
inst = _instance(db_session, srv.id, 113)
_create_log(db_session, "ghost", vmid=113, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
def test_non_numeric_resource_id_does_not_break_the_run(db_session, seed_users):
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 114)
db_session.add(AuditLog(
level="info", category="proxmox", action="vm_create", message="junk",
username=user.username, resource_type="vm", resource_id="not-a-vmid",
server_id=srv.id, success=True,
))
db_session.commit()
_create_log(db_session, user.username, vmid=114, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_deploy_task_wins_over_audit_log(db_session, seed_users):
srv = _server(db_session)
admin, user = seed_users["admin"], seed_users["user"]
inst = _instance(db_session, srv.id, 115)
_deploy_task(db_session, user.id, vmid=115, server_id=srv.id)
_create_log(db_session, admin.username, vmid=115, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_falls_back_to_logs_when_deploy_tasks_are_ambiguous(db_session, seed_users):
srv = _server(db_session)
admin, user = seed_users["admin"], seed_users["user"]
inst = _instance(db_session, srv.id, 116)
_deploy_task(db_session, user.id, vmid=116, server_id=srv.id)
_deploy_task(db_session, admin.id, vmid=116, server_id=srv.id)
_create_log(db_session, user.username, vmid=116, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
# ── Safety ──────────────────────────────────────────────────────────────────
def test_ambiguous_evidence_leaves_the_instance_ownerless(db_session, seed_users):
"""A reused VMID must not hand someone else's VM to the wrong user."""
srv = _server(db_session)
admin, user = seed_users["admin"], seed_users["user"]
inst = _instance(db_session, srv.id, 120)
_deploy_task(db_session, user.id, vmid=120, server_id=srv.id)
_deploy_task(db_session, admin.id, vmid=120, server_id=srv.id)
_create_log(db_session, admin.username, vmid=120, server_id=srv.id)
_create_log(db_session, user.username, vmid=120, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
def test_existing_owner_is_never_overwritten(db_session, seed_users):
srv = _server(db_session)
admin, user = seed_users["admin"], seed_users["user"]
inst = _instance(db_session, srv.id, 121, owner_id=user.id)
_deploy_task(db_session, admin.id, vmid=121, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_soft_deleted_instances_are_left_alone(db_session, seed_users):
from datetime import datetime, timezone
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 122, deleted_at=datetime.now(timezone.utc))
_deploy_task(db_session, user.id, vmid=122, server_id=srv.id)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
def test_instance_without_any_evidence_stays_ownerless(db_session, seed_users):
srv = _server(db_session)
inst = _instance(db_session, srv.id, 123)
_run(db_session)
assert _owner_of(db_session, inst.id) is None
def test_migration_is_idempotent(db_session, seed_users):
srv = _server(db_session)
user = seed_users["user"]
inst = _instance(db_session, srv.id, 124)
_deploy_task(db_session, user.id, vmid=124, server_id=srv.id)
_run(db_session)
_run(db_session)
assert _owner_of(db_session, inst.id) == user.id
def test_no_ownerless_instances_is_a_no_op(db_session, seed_users):
srv = _server(db_session)
_instance(db_session, srv.id, 125, owner_id=seed_users["user"].id)
_run(db_session)
remaining = db_session.connection().execute(
text("SELECT COUNT(*) FROM vm_instances WHERE owner_id IS NULL")
).scalar()
assert remaining == 0

View File

@@ -0,0 +1,85 @@
"""Tests for the setting:view split (migration 42).
``table_exists`` queries ``information_schema`` and is therefore
PostgreSQL-only; it is stubbed so the migration itself — whose statements are
portable — can run against the SQLite test database.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from migrations import migrations as mig
from migrations.migrations import migrate_setting_view_panel_only
from app.models import Role
@pytest.fixture(autouse=True)
def postgres_only_checks(monkeypatch):
monkeypatch.setattr(mig, "table_exists", lambda conn, table: True)
def _run(db_session):
"""Run the migration on a connection sharing the session's transaction."""
migrate_setting_view_panel_only(db_session.connection())
def _role(db_session, name, permissions):
role = Role(
name=name, display_name=name.title(), permissions=permissions,
is_system=False, is_active=True,
)
db_session.add(role)
db_session.commit()
return role
def _perms(db_session, name) -> dict:
raw = db_session.connection().execute(
text("SELECT permissions FROM roles WHERE name = :n"), {"n": name}
).scalar()
return raw if isinstance(raw, dict) else json.loads(raw or "{}")
def test_view_only_role_loses_the_grant(db_session):
_role(db_session, "viewer", {"vm:view": True, "setting:view": True})
_run(db_session)
assert "setting:view" not in _perms(db_session, "viewer")
assert _perms(db_session, "viewer")["vm:view"] is True
def test_panel_administrator_keeps_the_grant(db_session):
_role(db_session, "ops", {"setting:view": True, "setting:update": True})
_role(db_session, "sec", {"setting:view": True, "setting:manage": True})
_run(db_session)
assert _perms(db_session, "ops")["setting:view"] is True
assert _perms(db_session, "sec")["setting:view"] is True
def test_role_without_the_grant_is_untouched(db_session):
_role(db_session, "plain", {"vm:view": True})
_run(db_session)
assert _perms(db_session, "plain") == {"vm:view": True}
def test_runs_once_so_a_later_grant_survives(db_session):
_role(db_session, "viewer", {"setting:view": True})
_run(db_session)
# An admin re-grants it deliberately after the upgrade.
db_session.connection().execute(
text("UPDATE roles SET permissions = :p WHERE name = 'viewer'"),
{"p": json.dumps({"setting:view": True})},
)
_run(db_session)
assert _perms(db_session, "viewer")["setting:view"] is True

299
bootstrap.sh Executable file
View File

@@ -0,0 +1,299 @@
#!/bin/bash
#
# PVEmanager one-command installer.
#
# curl -fsSL https://get.<domain>/ | bash
# bash bootstrap.sh
#
# On a regular Debian/Ubuntu host: clones the repo and hands off to deploy.sh.
# On a Proxmox VE host: creates a Debian 12 LXC, installs the panel inside it,
# and registers this PVE host as the panel's first node using a locally
# created API token — no password ever leaves the host.
#
# Configuration (all optional, via env vars):
# PVEMANAGER_REPO Git URL to clone (default: official GitHub repo)
# PVEMANAGER_VERSION Git ref/tag to check out (default: latest tag, else main)
# PVEMANAGER_DIR Install directory (default: /opt/pvemanager)
#
# LXC-only (Proxmox host):
# PVEMANAGER_LXC_VMID (default: next free VMID)
# PVEMANAGER_LXC_HOSTNAME (default: pvemanager)
# PVEMANAGER_LXC_STORAGE rootfs storage (default: local-lvm)
# PVEMANAGER_LXC_TEMPLATE_STORAGE storage for the CT template (default: local)
# PVEMANAGER_LXC_BRIDGE (default: vmbr0)
# PVEMANAGER_LXC_CORES (default: 2)
# PVEMANAGER_LXC_MEMORY MB (default: 2048)
# PVEMANAGER_LXC_SWAP MB (default: 512)
# PVEMANAGER_LXC_DISK GB (default: 16)
# PVEMANAGER_LXC_UNPRIVILEGED 0 or 1 (default: 1)
set -euo pipefail
log() { echo -e "\033[0;34m[bootstrap]\033[0m $*"; }
ok() { echo -e "\033[0;32m[bootstrap]\033[0m $*"; }
err() { echo -e "\033[0;31m[bootstrap]\033[0m $*" >&2; }
require_root() {
if [ "$(id -u)" -ne 0 ]; then
err "This installer must be run as root."
exit 1
fi
}
# Prefer the latest git tag so an in-flight commit doesn't reach everyone
# installing at that exact moment; fall back to main if no tags exist yet.
resolve_ref() {
if [ -n "${PVEMANAGER_VERSION:-}" ]; then
echo "$PVEMANAGER_VERSION"
return
fi
local latest
latest=$(git ls-remote --tags --sort='-v:refname' "$REPO" 2>/dev/null \
| head -1 | sed 's#.*refs/tags/##; s/\^{}$//') || true
echo "${latest:-main}"
}
ensure_host_tools() {
command -v git >/dev/null 2>&1 && command -v curl >/dev/null 2>&1 && return
log "Installing git/curl..."
# On a PVE host without a paid subscription, apt's enterprise repo answers
# 401 and fails the whole update under `set -e` even though every other
# source succeeded; `apt-get install` right after still fails loudly if
# the package genuinely couldn't be found.
apt-get update -y || true
apt-get install -y --no-install-recommends git curl ca-certificates
}
# ---------------------------------------------------------------------------
# Branch A: plain Debian/Ubuntu host (also runs *inside* the LXC from Branch B)
# ---------------------------------------------------------------------------
install_branch_a() {
log "Plan: install Docker (if missing), clone ${REPO} @ ${REF} into ${DIR}, run deploy.sh --standalone."
log "Installing base dependencies..."
apt-get update -y || true
apt-get install -y --no-install-recommends git curl ca-certificates openssl jq
if [ ! -d "$DIR/.git" ]; then
log "Cloning ${REPO} (${REF}) into ${DIR}..."
git clone --branch "$REF" --depth 1 "$REPO" "$DIR"
else
log "${DIR} already exists — updating to ${REF}..."
git -C "$DIR" fetch --depth 1 origin "$REF"
git -C "$DIR" checkout -q "$REF" 2>/dev/null || git -C "$DIR" checkout -q "origin/$REF"
git -C "$DIR" reset --hard -q "origin/$REF" 2>/dev/null || true
fi
# deploy.sh's own dependency check prompts interactively when Docker is
# missing; installing it here up front means that check always passes
# and deploy.sh never has to ask anything.
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker..."
curl -fsSL https://get.docker.com | sh
fi
systemctl enable --now docker >/dev/null 2>&1 || service docker start >/dev/null 2>&1 || true
cd "$DIR"
bash deploy.sh --standalone
}
# ---------------------------------------------------------------------------
# Branch B: running directly on a Proxmox VE host
# ---------------------------------------------------------------------------
next_free_vmid() {
pvesh get /cluster/nextid --output-format json 2>/dev/null | tr -d '"' \
|| pvesh get /cluster/nextid 2>/dev/null | tr -d '"'
}
ensure_template() {
local storage="$1" tmpl avail
tmpl=$(pveam list "$storage" 2>/dev/null | awk '{print $1}' | grep 'debian-12-standard' | head -1)
if [ -n "$tmpl" ]; then
echo "$tmpl"
return
fi
log "Downloading Debian 12 LXC template into '${storage}'..." >&2
pveam update >/dev/null 2>&1 || true
avail=$(pveam available --section system 2>/dev/null | awk '{print $2}' | grep 'debian-12-standard' | sort -V | tail -1)
if [ -z "$avail" ]; then
err "No debian-12-standard template found via 'pveam available'. Run 'pveam update' and retry."
exit 1
fi
pveam download "$storage" "$avail" >&2
echo "${storage}:vztmpl/${avail}"
}
pick_token_name() {
local base="pvemanager" existing candidate i
existing=$(pvesh get /access/users/root@pam/token --output-format json 2>/dev/null \
| grep -oP '"tokenid":"\K[^"]+' || true)
if ! grep -qx "$base" <<<"$existing"; then
echo "$base"
return
fi
for i in $(seq 2 50); do
candidate="${base}-${i}"
if ! grep -qx "$candidate" <<<"$existing"; then
echo "$candidate"
return
fi
done
err "Could not find a free API token name for root@pam"
exit 1
}
register_this_host() {
local vmid="$1" ct_ip="$2" base="http://${2}:3001"
local admin_password jwt host_ip host_name token_name token_secret resp
admin_password=$(pct exec "$vmid" -- grep '^ADMIN_PASSWORD=' "${DIR}/backend/.env" 2>/dev/null | cut -d= -f2)
admin_password="${admin_password:-admin123}"
log "Logging into the freshly deployed panel..."
jwt=$(curl -fsS -X POST "${base}/api/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"${admin_password}\"}" 2>/dev/null \
| grep -oP '"access_token":"\K[^"]+' || true)
if [ -z "$jwt" ]; then
err "Could not log into the panel to register this node automatically."
err "Add it manually from the Nodes page once you've logged in."
return 1
fi
host_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1)
[ -z "$host_ip" ] && host_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
host_name=$(hostname -f 2>/dev/null || hostname)
token_name=$(pick_token_name)
log "Creating API token '${token_name}' for root@pam on this host..."
token_secret=$(pvesh create /access/users/root@pam/token/"${token_name}" \
--privsep 0 --comment "Created by PVEmanager bootstrap" --output-format json 2>/dev/null \
| grep -oP '"value":"\K[^"]+')
if [ -z "$token_secret" ]; then
err "Failed to create an API token on this host."
return 1
fi
log "Registering this host (${host_name} / ${host_ip}) in the panel..."
# The proxmox router is mounted under /proxmox (see main.py's
# include_router(..., prefix="/proxmox")) — only /api/auth/* lives at the
# bare /api root.
resp=$(curl -fsS -X POST "${base}/proxmox/api/servers" \
-H "Authorization: Bearer ${jwt}" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"${host_name}\",\"hostname\":\"${host_name}\",\"ip_address\":\"${host_ip}\",\"port\":8006,\"api_user\":\"root@pam\",\"api_token_name\":\"${token_name}\",\"api_token_value\":\"${token_secret}\",\"use_password\":false,\"verify_ssl\":false}" \
2>/dev/null || true)
if grep -q '"id"' <<<"$resp"; then
ok "Node registered in the panel."
else
err "Registration call did not return the expected response: ${resp}"
err "The API token '${token_name}' was still created on this host — add the server manually if needed."
fi
}
install_branch_b() {
local vmid hostname storage tmpl_storage bridge cores memory swap disk unpriv template
local tmp_clone ct_ip i admin_password
vmid="${PVEMANAGER_LXC_VMID:-$(next_free_vmid)}"
hostname="${PVEMANAGER_LXC_HOSTNAME:-pvemanager}"
storage="${PVEMANAGER_LXC_STORAGE:-local-lvm}"
tmpl_storage="${PVEMANAGER_LXC_TEMPLATE_STORAGE:-local}"
bridge="${PVEMANAGER_LXC_BRIDGE:-vmbr0}"
cores="${PVEMANAGER_LXC_CORES:-2}"
memory="${PVEMANAGER_LXC_MEMORY:-2048}"
swap="${PVEMANAGER_LXC_SWAP:-512}"
disk="${PVEMANAGER_LXC_DISK:-16}"
unpriv="${PVEMANAGER_LXC_UNPRIVILEGED:-1}"
log "Plan: create LXC #${vmid} ('${hostname}') on '${storage}' (${cores} vCPU, ${memory}MB RAM, ${disk}GB disk,"
log " DHCP on ${bridge}), install PVEmanager inside it, and register this host as its first node."
if pct status "$vmid" &>/dev/null; then
err "Container #${vmid} already exists. Set PVEMANAGER_LXC_VMID to a free id, or remove it first."
exit 1
fi
template=$(ensure_template "$tmpl_storage")
tmp_clone=$(mktemp -d)
trap 'rm -rf "$tmp_clone"' EXIT
log "Fetching the installer from ${REPO} (${REF})..."
git clone --branch "$REF" --depth 1 "$REPO" "$tmp_clone" >/dev/null 2>&1 \
|| { err "Could not clone ${REPO}"; exit 1; }
log "Creating container #${vmid}..."
pct create "$vmid" "$template" \
--hostname "$hostname" \
--cores "$cores" \
--memory "$memory" \
--swap "$swap" \
--rootfs "${storage}:${disk}" \
--net0 "name=eth0,bridge=${bridge},ip=dhcp" \
--unprivileged "$unpriv" \
--features "nesting=1,keyctl=1" \
--onboot 1
log "Starting container..."
pct start "$vmid"
log "Waiting for the container to get an IP address..."
ct_ip=""
for i in $(seq 1 30); do
ct_ip=$(pct exec "$vmid" -- sh -c "ip -4 -o addr show eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null || true)
[ -n "$ct_ip" ] && break
sleep 2
done
if [ -z "$ct_ip" ]; then
err "Container did not get an IP address from DHCP within 60s."
exit 1
fi
ok "Container #${vmid} is up at ${ct_ip}"
log "Installing PVEmanager inside the container (this takes a few minutes)..."
pct push "$vmid" "${tmp_clone}/bootstrap.sh" /root/bootstrap.sh
pct exec "$vmid" -- env \
PVEMANAGER_REPO="$REPO" PVEMANAGER_VERSION="$REF" PVEMANAGER_DIR="$DIR" \
bash /root/bootstrap.sh
log "Waiting for the panel API to come up..."
for i in $(seq 1 30); do
curl -fsS "http://${ct_ip}:3001/health" >/dev/null 2>&1 && break
sleep 2
done
if ! curl -fsS "http://${ct_ip}:3001/health" >/dev/null 2>&1; then
err "Panel did not come up at http://${ct_ip}:3001 — check 'pct exec ${vmid} -- docker compose -f ${DIR}/compose.yml logs'."
exit 1
fi
register_this_host "$vmid" "$ct_ip" || true
admin_password=$(pct exec "$vmid" -- grep '^ADMIN_PASSWORD=' "${DIR}/backend/.env" 2>/dev/null | cut -d= -f2)
echo ""
echo "=========================================="
ok "PVEmanager is up: http://${ct_ip}:3001"
echo "=========================================="
echo " Login: admin"
echo " Password: ${admin_password:-admin123}"
echo ""
echo "This host has been registered in the panel via a dedicated API token — no password was stored for it."
}
# ---------------------------------------------------------------------------
REPO="${PVEMANAGER_REPO:-https://github.com/markmorado/pvemanager.git}"
DIR="${PVEMANAGER_DIR:-/opt/pvemanager}"
require_root
ensure_host_tools
REF="$(resolve_ref)"
if [ -d /etc/pve ]; then
log "Detected a Proxmox VE host."
install_branch_b
else
log "Detected a regular Debian/Ubuntu host."
install_branch_a
fi

View File

@@ -15,6 +15,12 @@ services:
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./nginx/certbot/conf:/etc/letsencrypt:ro
- ./nginx/certbot/www:/var/www/certbot:rw
# nginx reads the certificate once at startup, so a certificate renewed by
# the certbot service below would keep being served in its old form until
# the container restarts — i.e. the site would start serving an expired
# certificate roughly 30 days after the first successful renewal.
# Reload every 6h so a renewed certificate is picked up in time.
command: /bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g "daemon off;"'
depends_on:
app:
condition: service_healthy
@@ -28,6 +34,14 @@ services:
interval: 30s
timeout: 3s
retries: 3
# nginx.conf asks for 2048 worker_connections, but Docker's default file
# descriptor limit is 1024 — so the real ceiling was a quarter of the
# configured one (each proxied connection needs an fd downstream and
# upstream), and nginx warned about it on every config test.
ulimits:
nofile:
soft: 8192
hard: 8192
deploy:
resources:
limits:
@@ -50,9 +64,13 @@ services:
profiles:
- ssl
# Override app service - remove port exposure (nginx will handle it)
# Override app service - remove port exposure (nginx will handle it).
# `ports: []` does NOT do that: compose concatenates list fields across -f
# files, so an empty list contributes nothing and 8000:8000 survived from
# compose.yml — leaving the API on the public interface over plain HTTP.
# !reset drops the inherited value outright.
app:
ports: []
ports: !reset []
expose:
- "8000"
volumes:
@@ -60,8 +78,8 @@ services:
- ./logs:/app/logs
- /etc/localtime:/etc/localtime:ro
# Override frontend service - remove port exposure (nginx will handle it)
# Same here: 3001:80 was still published despite `ports: []`.
frontend:
ports: []
ports: !reset []
expose:
- "80"

View File

@@ -5,7 +5,7 @@ services:
environment:
POSTGRES_DB: pvemanager
POSTGRES_USER: pvemanager
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pvemanager_secure_password}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required — run deploy.sh or bootstrap.sh to generate .env}
TZ: ${TZ:-Asia/Tashkent}
# Оптимизация для быстрого старта
POSTGRES_INITDB_ARGS: "--no-sync"
@@ -53,11 +53,13 @@ services:
- ./logs:/app/logs
- ./VERSION:/app/VERSION:ro
- /etc/localtime:/etc/localtime:ro
# App Store: кэш логотипов/данных каталога (persist across rebuilds)
- appstore_data:/app/data
# For system updates
- .:/project:rw
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://pvemanager:${POSTGRES_PASSWORD:-pvemanager_secure_password}@db:5432/pvemanager
- DATABASE_URL=postgresql://pvemanager:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/pvemanager
- PYTHONPATH=/app
- TZ=${TZ:-Asia/Tashkent}
- PROJECT_ROOT=/project
@@ -75,7 +77,7 @@ services:
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
start_period: 30s
# Ограничение ресурсов для стабильности
deploy:
resources:
@@ -110,6 +112,7 @@ services:
volumes:
db_data:
logs:
appstore_data:
networks:
pvemanager-network:

219
deploy.sh Normal file → Executable file
View File

@@ -6,6 +6,10 @@
set -e
set -o pipefail
# Save script path and original args for potential re-exec after docker group change
DEPLOY_SELF="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "${BASH_SOURCE[0]}")"
DEPLOY_QUOTED_ARGS="$(printf '%q ' "$@")"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -105,12 +109,11 @@ install_docker() {
;;
esac
# Add current user to docker group
if [ "$os" != "macos" ]; then
sudo usermod -aG docker $USER
print_warning "You may need to log out and back in for docker group changes to take effect"
# Add current user to docker group (activated via re-exec in check_requirements)
if [ "$os" != "macos" ] && [ "$(id -u)" -ne 0 ]; then
sudo usermod -aG docker "$USER"
fi
print_success "Docker installed successfully"
}
@@ -230,13 +233,23 @@ check_requirements() {
print_warning "Docker daemon is not running. Starting..."
sudo systemctl start docker 2>/dev/null || sudo service docker start 2>/dev/null || true
sleep 2
if ! docker info &> /dev/null; then
# Daemon may be running but the current session lacks the docker group.
# `usermod -aG docker` was applied above but only takes effect in a new
# login session. Re-exec the entire script under `sg docker` so the
# kernel picks up the supplementary GID without a logout/login cycle.
if sudo docker info &> /dev/null && \
getent group docker | grep -qw "$(id -un)" && \
[ -z "$_DEPLOY_REEXEC" ]; then
print_info "Activating docker group and restarting deploy..."
exec sg docker -c "_DEPLOY_REEXEC=1 bash \"$DEPLOY_SELF\" $DEPLOY_QUOTED_ARGS"
fi
print_error "Cannot connect to Docker daemon. Please start Docker and try again."
exit 1
fi
fi
print_success "Docker daemon is running"
}
@@ -371,16 +384,35 @@ setup_nginx_config() {
print_success "NGINX configuration created for domain: ${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
# /etc/letsencrypt/{live,archive,accounts} owned by root with mode 0700, so a
# plain `[ -d nginx/certbot/conf/live/$domain ]` from the (unprivileged) deploy
# user always fails with EACCES — even when the certificate is perfectly valid.
certificate_exists() {
local domain=$1
docker run --rm \
-v "$(pwd)/nginx/certbot/conf:/etc/letsencrypt" \
--entrypoint sh \
certbot/certbot -c \
"[ -s /etc/letsencrypt/live/${domain}/fullchain.pem ] && [ -s /etc/letsencrypt/live/${domain}/privkey.pem ]" \
>/dev/null 2>&1
}
obtain_ssl_certificate() {
local domain=$1
local email=$2
print_info "Obtaining SSL certificate for ${domain}..."
# Create certbot directories with proper permissions
# Create certbot directories. Only the ACME webroot needs relaxing — the
# rest belongs to certbot (root, 0700 on purpose: it holds private keys),
# and chmod -R here would only spam errors it cannot act on anyway.
mkdir -p nginx/certbot/conf nginx/certbot/www
chmod -R 755 nginx/certbot
chmod 755 nginx/certbot nginx/certbot/www 2>/dev/null || true
# Wait for nginx to be fully ready
print_info "Waiting for nginx to be ready..."
local max_wait=30
@@ -398,7 +430,9 @@ obtain_ssl_certificate() {
print_warning "NGINX may not be fully ready, continuing anyway..."
fi
# Request certificate using standalone certbot (more reliable)
# Request certificate via the webroot nginx is already serving.
# --keep-until-expiring makes an existing, still-valid certificate a plain
# success instead of an ambiguous "no action taken" result.
print_info "Requesting SSL certificate from Let's Encrypt..."
docker run --rm \
-v "$(pwd)/nginx/certbot/conf:/etc/letsencrypt" \
@@ -410,18 +444,26 @@ obtain_ssl_certificate() {
--agree-tos \
--no-eff-email \
--non-interactive \
--keep-until-expiring \
-d "${domain}"
local result=$?
if [ $result -eq 0 ] && [ -d "nginx/certbot/conf/live/${domain}" ]; then
print_success "SSL certificate obtained successfully"
# The certificate on disk is the source of truth, not certbot's exit code:
# a certificate that is present and not yet due for renewal is a success
# even on the runs where certbot reports it had nothing to do.
if certificate_exists "$domain"; then
if [ $result -ne 0 ]; then
print_warning "certbot exited with code ${result}, but a valid certificate for ${domain} is present"
fi
print_success "SSL certificate is available for ${domain}"
return 0
else
print_error "Failed to obtain SSL certificate"
print_warning "Continuing with HTTP only..."
return 1
fi
print_error "Failed to obtain SSL certificate (certbot exit code: ${result})"
print_warning "Check that ${domain} resolves to this server and that port 80 is reachable"
print_warning "Continuing with HTTP only..."
return 1
}
# Resilient Docker build: try with cache + --pull first; on failure retry once
@@ -479,6 +521,32 @@ check_stale_db_volume() {
fi
}
# Wait for the nginx container to settle into a running state and accept its
# configuration. A bad ssl_certificate path makes nginx exit at startup, so
# "up -d" returning 0 is not on its own proof that the site is being served.
verify_nginx_running() {
local max_wait=60
local waited=0
while [ $waited -lt $max_wait ]; do
# Use --format to get the raw container state ("running") instead of
# the human-readable STATUS column ("Up Xs (health: starting)") which
# does NOT contain the word "running" and only shows "healthy" after
# the first healthcheck interval (30s) — longer than the old 20s wait.
local state
state=$(docker compose -f compose.yml -f compose.prod.yml ps nginx --format '{{.State}}' 2>/dev/null)
if [ "$state" = "running" ]; then
if docker compose -f compose.yml -f compose.prod.yml exec -T nginx nginx -t >/dev/null 2>&1; then
return 0
fi
fi
sleep 2
waited=$((waited + 2))
done
return 1
}
deploy_with_nginx() {
local domain=$1
local use_ssl=$2
@@ -534,20 +602,33 @@ deploy_with_nginx() {
# Try to obtain SSL certificate
if obtain_ssl_certificate "$domain" "$email"; then
ssl_success=true
# Stop nginx to reconfigure with SSL
docker compose -f compose.yml -f compose.prod.yml stop nginx
# Setup SSL config
setup_nginx_config "$domain" true
print_info "Starting NGINX with SSL..."
docker compose -f compose.yml -f compose.prod.yml up -d nginx
# Start certbot renewal service
print_info "Starting certbot renewal service..."
docker compose -f compose.yml -f compose.prod.yml --profile ssl up -d certbot
if verify_nginx_running; then
ssl_success=true
print_success "NGINX is serving HTTPS for ${domain}"
# Start certbot renewal service
print_info "Starting certbot renewal service..."
docker compose -f compose.yml -f compose.prod.yml --profile ssl up -d certbot
else
# A crash-looping nginx takes the whole panel offline, which is
# worse than no HTTPS — roll back to the HTTP-only config.
print_error "NGINX failed to start with the SSL configuration:"
docker compose -f compose.yml -f compose.prod.yml logs --tail 20 nginx || true
print_warning "Rolling back to the HTTP-only configuration..."
docker compose -f compose.yml -f compose.prod.yml stop nginx
setup_nginx_config "$domain" false
docker compose -f compose.yml -f compose.prod.yml up -d nginx
ssl_success=false
fi
else
# Already running with non-SSL config, just continue
print_warning "Continuing with HTTP only (no SSL)..."
@@ -968,17 +1049,6 @@ install_pve_cli() {
return 1
fi
if [ "$(id -u)" -ne 0 ]; then
if [ "$mode" = "--auto" ]; then
print_warning "Не root — пропускаю авто-установку 'pve' CLI в /usr/local/bin."
print_info "Чтобы установить вручную: sudo bash $project_dir/deploy.sh --install-cli"
return 0
fi
print_error "Установка требует root-прав."
print_info "Выполните: sudo bash deploy.sh --install-cli"
return 1
fi
# Идемпотентность: если уже установлен и указывает на ту же директорию — пропускаем
if [ "$mode" = "--auto" ] && [ -f "$install_path" ]; then
if grep -q "^PVE_DIR=\"$project_dir\"" "$install_path" 2>/dev/null; then
@@ -987,24 +1057,71 @@ install_pve_cli() {
fi
fi
print_info "Установка pve CLI..."
print_info " Источник : $pve_source"
print_info " Установка : $install_path"
# Подставить реальный путь к директории панели
# Prepare the patched script in a temp file
local tmp_cli
tmp_cli=$(mktemp /tmp/pve-cli-XXXXXX)
if ! sed "s|PVE_DIR=\"/opt/pvemanager\"|PVE_DIR=\"$project_dir\"|g" \
"$pve_source" > "$install_path"; then
print_error "Не удалось записать $install_path"
"$pve_source" > "$tmp_cli"; then
rm -f "$tmp_cli"
print_error "Не удалось подготовить pve CLI"
return 1
fi
chmod +x "$install_path"
chmod 755 "$tmp_cli"
print_success "pve CLI установлен в $install_path"
print_info "Теперь вы можете использовать команду 'pve' из любого места."
# Try to install to /usr/local/bin (global PATH)
local installed=false
if [ "$(id -u)" -eq 0 ]; then
cp "$tmp_cli" "$install_path" && installed=true
else
# Not root — try sudo (password may already be cached from docker ops)
if sudo cp "$tmp_cli" "$install_path" 2>/dev/null && \
sudo chmod 755 "$install_path" 2>/dev/null; then
installed=true
fi
fi
if [ "$installed" = true ]; then
rm -f "$tmp_cli"
print_success "pve CLI установлен в $install_path"
print_info "Теперь вы можете использовать команду 'pve' из любого места."
if [ "$mode" != "--auto" ]; then
echo ""
"$install_path" help
fi
return 0
fi
# Fallback: install to ~/.local/bin (works without root)
local user_bin="${HOME}/.local/bin"
local user_install_path="$user_bin/pve"
mkdir -p "$user_bin"
cp "$tmp_cli" "$user_install_path"
rm -f "$tmp_cli"
print_success "pve CLI установлен в $user_install_path"
# Ensure ~/.local/bin is in PATH
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$user_bin"; then
local shell_rc=""
if [ -f "$HOME/.bashrc" ]; then
shell_rc="$HOME/.bashrc"
elif [ -f "$HOME/.profile" ]; then
shell_rc="$HOME/.profile"
fi
if [ -n "$shell_rc" ] && ! grep -q '\.local/bin' "$shell_rc" 2>/dev/null; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$shell_rc"
print_info "Добавлен ~/.local/bin в PATH ($shell_rc)"
fi
export PATH="$user_bin:$PATH"
print_warning "Для текущей сессии PATH обновлён. Для новых сессий выполните:"
print_info " source $shell_rc"
fi
if [ "$mode" != "--auto" ]; then
echo ""
"$install_path" help
"$user_install_path" help
fi
}

45
docs/appstore-catalog.md Normal file
View File

@@ -0,0 +1,45 @@
# App Store — M1 Catalog Service
Конвертер каталога `runtipi/runtipi-appstore` → таблица `catalog_apps`. Backend-этап
(UI — позже). Наполнение и отдача каталога через API.
## Компоненты
- Модель: `backend/app/models/appstore.py` → `CatalogApp` (таблица `catalog_apps`).
- Миграция: `migrate_appstore_catalog()` в `backend/migrations/migrations.py` (авто на старте).
- Сервис: `backend/app/services/appstore_catalog.py`
- `CatalogProvider` (ABC) → `RuntipiCatalogProvider` (скачивание tarball, парсинг).
- `sync_catalog(db, ref?)` — идемпотентный upsert + кэш логотипов.
- `start_catalog_scheduler()` — авто-синк раз в `CATALOG_SYNC_INTERVAL_HOURS` (APScheduler).
- API: `backend/app/api/appstore/catalog.py` (роутер подключён в `main.py`).
- Права: `app:view`, `app:install`, `app:manage` (`backend/app/rbac/permissions.py`).
## Настройки (env, все с дефолтами)
| Переменная | Дефолт | Назначение |
|---|---|---|
| `RUNTIPI_APPSTORE_REPO` | `runtipi/runtipi-appstore` | источник |
| `RUNTIPI_APPSTORE_REF` | `master` | ветка/tag/commit (рекомендуется фиксировать) |
| `APPSTORE_DATA_DIR` | `/app/data/appstore` | кэш логотипов (volume `appstore_data`) |
| `CATALOG_SYNC_INTERVAL_HOURS` | `24` | периодичность авто-синка |
| `APPSTORE_HOST_ARCH` | `amd64` | арх. нод (пометка неподдерживаемых) |
## API
```
GET /api/appstore/catalog?q=&category=&available_only=&limit=&offset= (app:view)
GET /api/appstore/catalog/meta (app:view)
GET /api/appstore/catalog/{app_id} (app:view)
GET /api/appstore/catalog/{app_id}/logo (app:view)
POST /api/appstore/catalog/sync (app:manage)
```
## Verification (DoD M1)
1. Запустить синхронизацию: `POST /api/appstore/catalog/sync` (или дождаться авто-джоба).
- Ожидаемо: `stats.total ≥ 150` (на текущем репозитории проходит фильтр ~265 приложений).
2. `GET /api/appstore/catalog/meta` → `total`, `last_synced_at`, список категорий.
3. `GET /api/appstore/catalog?q=uptime` → карточки с `logo_url`.
4. `GET /api/appstore/catalog/{app_id}` → `form_fields`, `compose_yaml`, `description_md`.
5. Идемпотентность: повторный `sync` не увеличивает `total` неконтролируемо (`created≈0`, `updated>0`).
6. Устойчивость (F-CAT-5): битый `config.json` одного приложения не роняет синк — `errors++`, остальные проходят.
Оффлайн-валидация парсера на реальном репозитории (stdlib): 271 папка, 270 с
config/compose/logo/описанием, 0 ошибок парсинга, 143 с формами, 5 deprecated
исключены → 265 проходят фильтр.

95
docs/appstore-engine.md Normal file
View File

@@ -0,0 +1,95 @@
# App Store — M2 App Engine (установка)
Установка приложения из каталога: клон золотого шаблона → `pct push` compose/.env →
`docker compose up -d` → health-check, с журналом шагов, статусами и WS-прогрессом.
Ядро переиспользует пайплайн M0 (`app/appstore/pipeline.py`).
## Компоненты
- Модели: `InstalledApp`, `AppOperation` (`backend/app/models/appstore.py`).
- Миграция: `migrate_appstore_installed()` (таблицы `installed_apps`, `app_operations`).
- Движок: `backend/app/services/appstore_engine.py` — `install`, `retry_install`, `delete_app`,
`build_env` (платформенные переменные ТЗ 5.3 + форма + генерация `random`-секретов).
- API: `backend/app/api/appstore/apps.py`.
- Прогресс: WebSocket `/ws/tasks`, события `appstore_operation_update` / `appstore_app_deleted`.
## Обязательная настройка
`APPSTORE_GOLDEN_TEMPLATE` — volid золотого vztmpl с предустановленным Docker,
напр. `local:vztmpl/golden-docker_12_amd64.tar.zst` (готовится вручную, M0). Без
него установка отклоняется с понятной ошибкой.
## API
```
POST /api/appstore/apps/install (app:install) → {installed_app_id, operation_id, vmid, secrets}
GET /api/appstore/apps (app:view)
GET /api/appstore/apps/{id} (app:view) → + catalog, update_available
GET /api/appstore/apps/{id}/operations (app:view) → журнал шагов
GET /api/appstore/apps/{id}/credentials (app:view) → секреты формы (показать один раз)
POST /api/appstore/apps/{id}/retry (app:manage) → повтор без пересоздания LXC
DELETE /api/appstore/apps/{id} (app:manage) → stop + destroy LXC + удалить запись
```
`install` тело запроса: `app_id, name, server_id, node?, form_answers{}, cores,
memory, disk, storage, bridge, ostemplate?`.
## Поведение (ТЗ 6.1)
- Пайплайн асинхронный (ThreadPoolExecutor), статус и журнал шагов пишутся в БД,
прогресс транслируется по WS.
- `secrets` (пароли/сгенерированные random) возвращаются один раз в ответе install;
в БД хранятся зашифрованно (Fernet, `env_encrypted`).
- Сбой любого шага → `installed_apps.status='error'`, `app_operations.status='failed'`
с текстом ошибки; доступны «Повторить» (`/retry`) и «Удалить» (`DELETE`).
- Идемпотентность повтора: vmid сохраняется в записи; при повторе контейнер не
пересоздаётся (проверка существования по vmid), пайплайн продолжает с доставки/запуска.
- `pct exec`/`pct push` — через SSH на ноду; значения `.env` очищаются от переводов
строк, файлы доставляются base64 → `pct push` (без shell-инъекций, S-4).
## Verification (DoD M2 — backend)
Предпосылки: настроен `APPSTORE_GOLDEN_TEMPLATE`, каталог синхронизирован (M1),
SSH-доступ панели к ноде.
1. `POST /api/appstore/apps/install {app_id:"uptime-kuma", name:"uk1", server_id:1}`
→ `202`-подобный ответ с `operation_id`; по WS приходят шаги; статус → `running`.
2. `GET /api/appstore/apps/{id}` → `status:running`, `url:http://IP:port`.
3. Открыть `url` — приложение отвечает.
4. Приложение с формой (напр. `wordpress`): random-пароли БД генерируются,
`GET /apps/{id}/credentials` их возвращает.
5. Сбой (напр. неверный шаблон) → `status:error`, журнал с текстом; `POST /retry`
не создаёт второй LXC; `DELETE` сносит контейнер и запись.
UI (сетка, мастер установки, My Apps) — под-этап M2 (готов).
## M3 — Lifecycle + реконсиляция
Добавлено в `appstore_engine.py` / `api/appstore/apps.py`:
```
POST /api/appstore/apps/{id}/action/{start|stop|restart} (app:manage)
GET /api/appstore/apps/{id}/logs?tail=200 (app:view) — docker compose logs через pct exec
POST /api/appstore/apps/reconcile (app:view) — сверка LXC↔БД
```
- `lifecycle_action` — start/stop/restart LXC через ProxmoxClient, обновление статуса + WS.
- `get_logs` — `pct exec … docker compose logs --tail N` по SSH (ТЗ 6.5, по запросу).
- `reconcile` (ТЗ 6.6) — фон каждые 60с (`start_reconcile_scheduler`) + при открытии «My Apps»:
пропавший контейнер → `orphaned`; статус синхронизируется с Proxmox (running/stopped).
- UI: кнопки Start/Stop/Restart/Logs/Delete в My Apps, диалог логов, бейдж `orphaned`.
DoD M3: все операции работают, orphan-детект работает.
## M4 — Update + Rollback (снапшоты Proxmox)
```
POST /api/appstore/apps/{id}/update (app:manage) body: {form_answers?}
POST /api/appstore/apps/{id}/rollback (app:manage)
```
- `update` (ТЗ 6.3): снапшот `preupd_<version>_<timestamp>` → `pct push` свежего
compose из каталога (`poc_update`) → `docker compose pull && up -d` → health-check.
Успех → новая версия в БД, хранится последний 1 pre-update снапшот (старые удаляются).
Провал health → статус `update_failed`, доступен откат. `.env` сохраняется, новые
поля формы дозапрашиваются (`form_answers`). Версия/tipi снапшота фиксируются в
`installed_apps.snapshot_version/snapshot_tipi_version` (миграция добавляет колонки).
- `rollback` (ТЗ 6.4): stop → `rollback_container_snapshot(start=1)` → start →
health-check → возврат прежней версии в БД. UI обязательно предупреждает, что
откат возвращает и ДАННЫЕ приложения на момент снапшота.
- UI: кнопки Update (если `update_available`) и Rollback (если есть `last_snapshot`)
в My Apps, с подтверждением-предупреждением.
DoD M4: обновление и откат Nextcloud проходят без потери данных (данные — в томах
контейнера, снапшот покрывает весь CT).

74
docs/appstore-poc.md Normal file
View File

@@ -0,0 +1,74 @@
# App Store — M0 Proof of Concept
Изолированный backend-каркас, который вручную проверяет ключевую схему модуля
App Store: **create (из золотого шаблона) → push → exec → health-check** для
одного приложения. Роутеры FastAPI НЕ регистрируются — работающая панель не
затрагивается. Это seed будущего `services/appstore_engine.py`.
Код: `backend/app/appstore/` (`pipeline.py`, `cli.py`, `samples/`).
## Предпосылки
1. **Золотой шаблон LXC** уже подготовлен и лежит как `vztmpl` в storage ноды:
- Debian 12, unprivileged;
- предустановлены Docker Engine + Docker Compose plugin, `curl`, `jq`;
- экспортирован в шаблон (`vzdump` → файл в `…/template/cache/*.tar.zst`),
видимый как `local:vztmpl/<имя>.tar.zst`.
- Features `nesting=1,keyctl=1` PoC выставляет сам при создании CT (это
параметр контейнера, а не свойство шаблона).
2. **SSH-доступ панели к ноде root-ом** (тот же механизм, что использует остальная
панель — `setup_ssh.sh` / пароль root на сервере). Нужен для `pct exec`/`pct push`.
3. Proxmox-сервер уже добавлен в панель (есть `server_id` в БД).
4. Запуск — в окружении бэкенда (доступ к БД панели и `FERNET_KEY`), например
внутри backend-контейнера: `docker compose exec app python -m app.appstore.cli ...`.
## Запуск
Установка тестового приложения (по умолчанию — uptime-kuma, одно­сервисное):
```bash
python -m app.appstore.cli install \
--server-id 1 \
--ostemplate local:vztmpl/golden-docker_12_amd64.tar.zst \
--name poc-uptime-kuma \
--port 3001
```
Опционально: `--node`, `--storage`, `--bridge`, `--cores`, `--memory`, `--disk`,
`--compose-file <path>`, `--env-file <path>`.
Повторяемость — снести контейнер:
```bash
python -m app.appstore.cli teardown --server-id 1 --vmid <VMID>
```
## Что делает пайплайн (`pipeline.poc_install`)
1. `_get_proxmox_client()` → подключение к Proxmox, выбор ноды.
2. `create_lxc_container(ostemplate=<golden>, features="nesting=1,keyctl=1", …)` +
`wait_for_task` — контейнер из золотого шаблона.
3. `start_container` → ожидание IP (`pct exec … hostname -I`).
4. `mkdir /opt/app /opt/app-data`, создание Docker-сети `tipi_main_network`.
5. Доставка `docker-compose.yml` и `.env` через **base64 → файл на ноде →
`pct push`** (без shell-инъекций, S-4).
6. `pct exec … docker compose --env-file .env up -d`.
7. Health-check `GET http://<ip>:<port>` (ретраи 5с, любой HTTP-ответ = успех).
Прогресс шагов печатается в лог (в реальном движке станет WS-broadcast'ом).
## Definition of Done (M0)
- Из золотого шаблона поднимается контейнер, и `docker compose up` тестового
приложения проходит с первой попытки.
- `poc install` завершается статусом `running`, выводит `http://IP:порт`, и
Uptime Kuma открывается по этому адресу.
## Известные ограничения PoC
- Нет записи в БД (таблицы `installed_apps`/`app_operations` — этап M1+); PoC
наблюдаем только по логам и через сам Proxmox.
- Health-check IP определяется через `hostname -I` (DHCP). Статический IP из IPAM
— на следующем этапе (готовый `ipam_service` уже есть).
- Для «тяжёлых»/privileged приложений unprivileged LXC может не подойти — это
ожидаемо и отражено в рисках ТЗ.

112
docs/golden-template.md Normal file
View File

@@ -0,0 +1,112 @@
# Золотой шаблон LXC для App Store
> **Есть автоматизация:** App Store → «Золотой шаблон» в UI (или `POST /api/appstore/golden-template`)
> прогоняет ровно эти шаги за вас на выбранном сервере/ноде (`backend/app/appstore/golden_template.py`).
> Результат сохраняется **отдельно по каждому Proxmox-серверу** (`server_id`) — vztmpl физически
> лежит на storage конкретного сервера и не виден на других, поэтому сборка на одном сервере не
> заменяет и не ломает шаблон, настроенный для другого. Ручная процедура ниже нужна только для
> отладки/кастомизации шага, которого нет в автоматизации.
Разовая ручная подготовка (ТЗ 4). Из этого шаблона App Engine клонирует контейнеры
для приложений. Результат — файл `vztmpl` в storage ноды, на который указывает
переменная `APPSTORE_GOLDEN_TEMPLATE` (напр. `local:vztmpl/golden-docker_12_amd64.tar.zst`) —
это лишь дефолт-фоллбэк для серверов, для которых ещё не собран свой шаблон.
## Требования к шаблону (ТЗ 4.1)
- Debian 12 (bookworm), **unprivileged**.
- Features `nesting=1,keyctl=1` (App Engine выставляет их при создании CT, но полезно проверить).
- Предустановлено: Docker Engine (stable), Docker Compose plugin, `curl`, `jq`.
- Docker-сеть `tipi_main_network` (создаётся при первом старте контейнера движком, но
можно создать заранее — compose-файлы Runtipi ссылаются на неё как external).
- Каталоги `/opt/app-data` (APP_DATA_DIR) и `/opt/app` (compose + .env).
- logrotate для Docker json-file логов (max-size=10m, max-file=3).
## Пошагово
1. Создать временный unprivileged CT из стандартного шаблона Debian 12 на ноде.
> ⚠️ Не хардкодьте версию шаблона. Точное имя (напр. `debian-12-standard_12.12-1_amd64.tar.zst`)
> меняется со временем — всегда берите его из вывода `pveam available`, иначе `pveam download`
> упадёт с `no such template`, а `pct create` — с `volume ... does not exist`.
```bash
pveam update
# 1a. Узнать актуальное имя шаблона (версия ниже — только пример):
pveam available --section system | grep debian-12-standard
# -> system debian-12-standard_12.12-1_amd64.tar.zst (используйте это имя далее)
# 1b. Скачать именно это имя в storage local:
TMPL=debian-12-standard_12.12-1_amd64.tar.zst # подставьте имя из вывода 1a
pveam download local "$TMPL"
# 1c. Убедиться, что файл реально появился в vztmpl:
pvesm list local --content vztmpl
# 1d. Создать CT, указав ТО ЖЕ имя, что скачали:
pct create 9000 "local:vztmpl/$TMPL" \
--hostname golden-docker --unprivileged 1 \
--features nesting=1,keyctl=1 \
--cores 2 --memory 2048 --swap 512 \
--rootfs local-lvm:8 --net0 name=eth0,bridge=vmbr0,ip=dhcp
pct start 9000
```
2. Внутри контейнера установить Docker и утилиты:
```bash
pct exec 9000 -- bash -lc '
apt-get update &&
apt-get install -y ca-certificates curl jq gnupg &&
install -m 0755 -d /etc/apt/keyrings &&
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc &&
chmod a+r /etc/apt/keyrings/docker.asc &&
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update &&
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin &&
systemctl enable docker
'
```
3. Подготовить каталоги, сеть и logrotate:
```bash
pct exec 9000 -- bash -lc '
mkdir -p /opt/app /opt/app-data &&
docker network inspect tipi_main_network >/dev/null 2>&1 || docker network create tipi_main_network &&
cat >/etc/docker/daemon.json <<EOF
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
EOF
systemctl restart docker
'
```
4. Проверка (Definition of Done, ТЗ 4.2): тестовый nginx поднимается с первой попытки:
```bash
pct exec 9000 -- bash -lc '
mkdir -p /opt/app && cd /opt/app &&
printf "services:\n web:\n image: nginx:alpine\n ports:\n - \"80:80\"\n" > docker-compose.yml &&
docker compose up -d && sleep 3 && curl -sf http://localhost:80 >/dev/null && echo OK
'
pct exec 9000 -- bash -lc 'cd /opt/app && docker compose down'
```
5. Очистить временное состояние и превратить в шаблон:
```bash
pct exec 9000 -- bash -lc 'apt-get clean && rm -rf /var/lib/apt/lists/* /opt/app/docker-compose.yml'
pct stop 9000
# Экспорт в vztmpl-шаблон через vzdump:
vzdump 9000 --dumpdir /var/lib/vz/template/cache --mode stop --compress zstd
```
Полученный файл появится как `local:vztmpl/vzdump-lxc-9000-*.tar.zst`. Переименуйте по вкусу,
напр. `golden-docker_12_amd64.tar.zst`, и удалите временный CT 9000.
> Примечание: если в вашей версии Proxmox `vzdump` кладёт архив в `dump/`, скопируйте
> его в `template/cache/`, чтобы он был виден как `vztmpl` при создании контейнеров.
6. Указать движку шаблон:
```
APPSTORE_GOLDEN_TEMPLATE=local:vztmpl/golden-docker_12_amd64.tar.zst
```
(env бэкенда; см. docs/appstore-engine.md).
## Пересоздание
Шаблон готовится заново по этому же списку при обновлении Docker/Debian. arm64 —
аналогично, из arm64-шаблона Debian на ARM-ноде (см. `APPSTORE_HOST_ARCH=arm64`).

234
docs/roadmap.md Normal file
View File

@@ -0,0 +1,234 @@
# План развития PVEmanager
Документ описывает шесть направлений развития панели, согласованных 24.07.2026.
Два из них уже реализованы, четыре — предстоят. Для каждого этапа указаны цель,
затрагиваемые файлы, шаги и критерии готовности, чтобы задачу мог взять любой
разработчик без дополнительного контекста.
## Статус
| # | Направление | Статус | Приоритет |
|---|---|---|---|
| 1 | Автосоздание API-токена при подключении ноды | ✅ Сделано | — |
| 2 | Графики метрик нод | ✅ Сделано (не проверено визуально) | — |
| 3 | Одна команда установки (`curl \| bash`) | 🟡 Загрузчик готов (ветка B), не проверен на реальном PVE | 1 |
| 4 | Интеграция с Proxmox Backup Server | ⬜ Запланировано | 2 |
| 5 | Redis: вынос воркера задач и общий кэш | ⬜ Запланировано | 3 |
| 6 | Внешний стек метрик и логов (опционально) | ⬜ Запланировано | 4 |
Порядок выбран так: сначала дистрибуция (от неё зависит рост аудитории), затем
крупная продуктовая фича, затем инфраструктурные улучшения. Этап 6 — самый
дорогой и наименее обязательный, поэтому идёт последним.
---
## ✅ Этап 1. Автосоздание API-токена
**Что сделано.** При добавлении сервера пользователь вводит логин и пароль,
панель подключается ими один раз, создаёт на ноде собственный API-токен и
дальше работает по нему. Токен отзывается из UI Proxmox, не даёт SSH- и
веб-логина и виден в аудите PVE под своим именем.
- `backend/app/proxmox/token_provisioning.py` — создание токена (`privsep=0`),
выбор свободного имени (`pvemanager`, при коллизии `pvemanager-2`), откат
через `revoke_api_token`.
- `POST /api/servers` (`backend/app/api/proxmox/servers.py`) — поле
`auto_create_token`; при сбое коммита созданный токен отзывается, чтобы на
ноде не оставался сирота с потерянным секретом.
- `POST /api/servers/{id}/provision-token` — перевод уже подключённого сервера
с пароля на токен по сохранённому паролю.
- `api_token_value` переведён на `EncryptedString` (`backend/app/models/proxmox.py`) —
раньше токены лежали в БД открытым текстом, в отличие от паролей.
- Фронтенд: выбор из трёх режимов подключения в `NodesPage.tsx`, кнопка
«Перевести на API-токен» на карточке сервера.
**Принятые решения.** Пароль после создания токена остаётся в БД
(зашифрованным) — он нужен `ProxmoxClient._password` для SSH-фолбэков вроде
`pvecm delnode`. Токен создаётся с `privsep=0`, то есть наследует права
пользователя.
## ✅ Этап 2. Графики метрик нод
**Что сделано.** Вкладка «Графики» в `NodeDetailPage`: CPU, IO delay, память,
load average, сеть in/out, swap, корневая ФС. Пресеты `1h/24h/7d/30d/1y`.
- `frontend/src/components/shared/MetricChart.tsx` — общий `ChartCard`,
переиспользуется вкладкой инстансов.
- `useNodeMetrics` в `frontend/src/hooks/use-nodes.ts` — нормализация сырого RRD
(PVE отдаёт `cpu`/`iowait` долями 0..1).
- `frontend/src/features/nodes/NodeGraphs.tsx`.
Бэкенд не менялся — эндпоинт `/api/{server_id}/node/rrddata` существовал ранее.
Дисковых I/O и IOPS в наборе нет: PVE их в node RRD не отдаёт.
**Открытый хвост.** Рендер с реальными данными не проверялся визуально.
---
## 🟡 Этап 3. Установка одной командой
**Что сделано.** `bootstrap.sh` в корне репозитория — единый загрузчик для обеих
веток, определяет среду по наличию `/etc/pve`:
- **Ветка A** (`install_branch_a`): ставит Docker (через `get.docker.com`, если
его ещё нет) и git/curl/openssl/jq, клонирует репозиторий в
`PVEMANAGER_DIR` (`/opt/pvemanager` по умолчанию) на выбранный `git`-реф
(`PVEMANAGER_VERSION`, по умолчанию — последний тег репозитория, иначе
`main`), передаёт управление `deploy.sh --standalone`. Docker ставится
заранее нарочно: интерактивный запрос внутри `check_requirements()` в
`deploy.sh` иначе заблокировал бы неинтерактивный запуск из LXC.
- **Ветка B** (`install_branch_b`): создаёт LXC (Debian 12, DHCP на `vmbr0`,
`nesting=1,keyctl=1` для Docker внутри), параметры (vmid, storage, bridge,
ресурсы) настраиваются переменными `PVEMANAGER_LXC_*`; шаблон скачивается
через `pveam`, если его ещё нет. Затем клонирует репозиторий во временную
директорию **на хосте** только чтобы забрать актуальную версию самого
`bootstrap.sh`, кладёт его в контейнер через `pct push` и выполняет там —
внутри LXC `/etc/pve` не существует, поэтому скрипт сам уходит в ветку A.
После того как панель поднялась (`GET /health`), хост создаёт токен
`root@pam!pvemanager` локально через `pvesh create ... --privsep 0`,
логинится в панель под `admin`/сгенерированным паролем и регистрирует
сам себя (`POST /api/servers`) — пароль от Proxmox никуда не передаётся.
**Принятые решения при реализации.**
- Убран небезопасный дефолт `${POSTGRES_PASSWORD:-pvemanager_secure_password}`
в `compose.yml` (заменён на `${POSTGRES_PASSWORD:?...}`) — раньше стек мог
молча подняться с предсказуемым паролем БД при отсутствии `.env`.
- Реф для клонирования (`resolve_ref()`) уже поддерживает пиннинг по
последнему git-тегу через `git ls-remote --tags`, но в репозитории пока нет
ни одного тега — до первого релиза загрузчик fallback'ится на `main`.
**Не сделано / не проверено.**
- Скрипт **не тестировался на живом Proxmox-хосте** — нет доступного стенда
в этой сессии. Нужна проверка: создание LXC, установка Docker внутри
unprivileged-контейнера, полный проход `deploy.sh --standalone`, логин и
регистрация ноды.
- Инфраструктура раздачи (домен `get.<домен>`, статика с нужными
`Content-Type`/`Cache-Control`, публикация sha256, второй путь установки в
README) — не делалась, требует внешних ресурсов (домен, хостинг).
`bash bootstrap.sh` уже работает как локальный путь установки.
- `pve --help` не проверялся в связке с этим потоком (устанавливается тем же
`deploy.sh` через `install_pve_cli --auto`, логика не менялась).
**Осталось до полного закрытия этапа** (инфраструктурная часть, не код):
- Домен `get.<домен>` под контролем проекта + TLS, раздача статики
(Cloudflare Pages/Worker или R2) с `Content-Type: text/plain` и коротким
`Cache-Control`.
- Публикация sha256 `bootstrap.sh` и второй путь установки в README
(«скачать → прочитать → запустить»).
- Первый git-тег в репозитории, чтобы пиннинг версии в `resolve_ref()` начал
реально на что-то указывать (сейчас fallback на `main`).
- Живой прогон на Debian/Ubuntu-машине и на PVE-хосте — см. «Не сделано /
не проверено» выше.
**Готово, когда:**
- на чистой Debian/Ubuntu-машине одна команда поднимает работающую панель;
- на PVE-хосте та же команда создаёт LXC, ставит панель внутрь и регистрирует
ноду без ввода пароля;
- `pve --help` отвечает;
- повторный запуск обновляет установку, не ломая её.
## ⬜ Этап 4. Интеграция с Proxmox Backup Server
**Цель.** Управлять PBS напрямую, а не только через PVE.
**Что уже есть.** PBS подключается к PVE как storage типа `pbs`, поэтому
бэкап и восстановление уже работают через `backend/app/api/proxmox/backup.py`
(storages, native jobs, restore, bulk restore).
**Чего не хватает** (не видно через PVE API): использование datastore и
дедупликация, задания verify/prune/GC, namespaces, sync- и tape-задания,
браузер снапшотов с восстановлением отдельных файлов.
**Шаги.**
1. Модель `PBSServer`: host, порт (по умолчанию 8007), `api_user`, токен
(`user@pbs!tokenid`), fingerprint сертификата, `verify_ssl`. Секреты — через
`EncryptedString`.
2. Клиент `backend/app/pbs/client.py` по образцу `ProxmoxClient` с миксинами:
datastores, snapshots, verify/prune/GC, namespaces.
3. API-роутер `backend/app/api/pbs/` + права в `backend/app/rbac/permissions.py`.
4. Фронтенд: раздел PBS со списком серверов, datastore-обзором и браузером
снапшотов.
5. Связать с существующим разделом бэкапов: если storage типа `pbs` соответствует
зарегистрированному PBS-серверу — показывать расширенные данные.
**Готово, когда** можно добавить PBS-сервер, увидеть занятость datastore и
коэффициент дедупликации, запустить verify/prune и просмотреть содержимое
снапшота.
**Оценка.** Сопоставимо по объёму со всем текущим модулем бэкапов.
## ⬜ Этап 5. Redis: вынос воркера и общий кэш
**Цель.** Вынести исполнение фоновых задач из процесса `app` в отдельный
контейнер и убрать состояние, которое сейчас живёт только в памяти процесса.
**Текущее состояние.** `TaskQueueService` хранит задачи в Postgres, а
исполняются они внутри `app` через APScheduler при `UVICORN_WORKERS=1`.
Последствия: воркеры не масштабируются, задача в статусе `running` зависает при
рестарте контейнера, нет ретраев.
**Важно:** очередь в Postgres сама по себе не проблема — для домашней нагрузки
её достаточно. Выигрыш даёт именно вынос воркера; Redis нужен как брокер и
попутно закрывает три места, которые сейчас ломаются при масштабировании:
- `connection_cache` в `backend/app/proxmox/client.py` — словарь в памяти;
- `_rrd_cache` в `backend/app/services/metrics_broadcaster.py` — то же;
- `websocket_manager.py` — при более чем одном uvicorn-воркере рассылка не
работает без pub/sub.
**Шаги.**
1. Добавить сервис `redis` в `compose.yml` (alpine-образ, ограничение памяти).
2. Подключить **ARQ** как брокер (asyncio-native; Celery для этой задачи
избыточен). Postgres остаётся источником правды по истории задач.
3. Вынести обработку `TaskQueue` в отдельный контейнер `worker`.
4. Перевести `connection_cache` и `_rrd_cache` на Redis.
5. Перевести WebSocket-рассылку на Redis pub/sub, после чего можно поднять
`UVICORN_WORKERS`.
6. Добавить ретраи и переустановку статуса зависших задач при старте воркера.
**Готово, когда** задачи исполняются в отдельном контейнере, переживают рестарт
`app`, а панель корректно работает при `UVICORN_WORKERS > 1`.
## ⬜ Этап 6. Внешний стек метрик и логов
**Цель.** Снять с панели хранение метрик и дать историю глубже, чем позволяет
RRD, — но не навязывая всем пользователям тяжёлый стек мониторинга.
**Ключевой факт.** Собирать метрики самим не нужно: у Proxmox есть встроенный
external metric server (протоколы InfluxDB и Graphite) — нода push-ит метрики
наружу без всякого кода в панели. Для pull-модели существует
`prometheus-pve-exporter`.
**Решения по составу.**
- **VictoriaMetrics вместо Prometheus** и **VictoriaLogs вместо Loki** — заметно
меньше RAM и диска при совместимом PromQL/LogsQL.
- **Grafana необязательна.** Если панель сама ходит в PromQL, Grafana нужна
только продвинутым пользователям — подключается опционально.
- Весь стек включается через `docker compose --profile monitoring`, а **не**
входит в поставку по умолчанию: для типовой домашней установки это дороже
самой панели.
**Шаги.**
1. Профиль `monitoring` в `compose.yml`.
2. В панели — переключаемый источник метрик: `native` (RRD через PVE API, как
сейчас) либо `victoriametrics` (PromQL). Настройка в разделе Settings.
3. Абстрагировать слой доступа к данным на фронтенде, чтобы `GraphsTab` и
`NodeGraphs` не переписывать при смене источника.
4. Инструкция по настройке external metric server на ноде.
5. Опциональная Grafana с преднастроенными дашбордами.
**Готово, когда** при выключенном профиле всё работает как сейчас, а при
включённом графики строятся из VictoriaMetrics с историей глубже RRD.
**Зависимость.** Этап 2 намеренно сделан на RRD. При выполнении этого этапа
слой доступа к данным нужно спрятать за один хук, а не переписывать компоненты.
---
## Технический долг, замеченный попутно
- **Хардкод русских строк** в `frontend/src/features/instances/tabs/GraphsTab.tsx`:
«IOPS чтение», «IOPS запись», «Заполненность диска», «Все», «Сбросить»,
«Нет данных» — не проходят через i18n.
- **`graphify update .`** отказывается перезаписывать граф: AST-версия даёт 5938
узлов против 5959 в текущем `graph.json`. Нужно решить, форсить ли перестроение.

View File

@@ -2,9 +2,11 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# BuildKit-кеш npm — повторные сборки переиспользуют скачанные пакеты
# BuildKit-кеш npm — повторные сборки переиспользуют скачанные пакеты.
# npm install вместо npm ci: кросс-платформенный lockfile (Windows-host)
# не резолвит Linux-optional deps (@emnapi/core и др.), npm ci падает.
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit --no-fund
npm install --prefer-offline --no-audit --no-fund
# ── Dev stage: Vite HMR (используется compose.dev.yml) ───────────────────────
FROM deps AS dev

View File

@@ -27,6 +27,12 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
# Загрузка ISO/образов: файлы многогигабайтные, дефолтный
# client_max_body_size 1m этого server{} блока и короткие таймауты
# им не подходят.
client_max_body_size 20g;
proxy_send_timeout 300s;
client_body_timeout 3600s;
}
# Settings API proxy

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@novnc/novnc": "^1.6.0",
"@tailwindcss/vite": "^4.2.2",
@@ -30,8 +30,12 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-markdown": "^9.1.0",
"react-router": "^7.14.0",
"recharts": "^3.8.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",

View File

@@ -1,21 +1,12 @@
import { useEffect } from 'react';
import { RouterProvider } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialogProvider } from '@/components/shared/ConfirmDialog';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
import { queryClient } from '@/lib/query-client';
export default function App() {
const loadUser = useAuthStore((s) => s.loadUser);

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { useNavigate } from 'react-router';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DialogPortal, DialogOverlay } from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store';
import { useCommandPaletteStore } from '@/stores/command-palette-store';
import { navGroups, canSeeNavItem, personalNavItems } from '@/lib/nav-items';
import { cn } from '@/lib/utils';
/**
* Global Ctrl/Cmd+K palette for jumping between sections. Mounted once in
* AppLayout; reuses the same navGroups/permission gating as the sidebar so
* the two never drift out of sync.
*/
export default function CommandPalette() {
const { open, setOpen, toggle } = useCommandPaletteStore();
const navigate = useNavigate();
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const permissions = user?.permissions;
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
function onKeyDown(e: globalThis.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggle();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [toggle]);
const flatItems = useMemo(() => {
const q = query.trim().toLowerCase();
const matches = (item: { label: string; path: string }) => {
if (!q) return true;
const label = t(`nav.${item.label.toLowerCase()}`, item.label);
return label.toLowerCase().includes(q) || item.path.toLowerCase().includes(q);
};
return [
...navGroups.flatMap((group) =>
group.items
.filter((item) => canSeeNavItem(item, isAdmin, permissions))
.filter(matches)
.map((item) => ({ ...item, group: group.title }))
),
// Not permission-gated: reachable by anyone who is logged in.
...personalNavItems.filter(matches).map((item) => ({ ...item, group: 'Account' })),
];
}, [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);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setActiveIndex(0);
};
const go = (path: string) => {
setOpen(false);
navigate(path);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const item = flatItems[activeIndex];
if (item) go(item.path);
}
};
let groupCursor = '';
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="command-palette"
className="fixed top-24 left-1/2 z-50 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-2xl ring-1 ring-foreground/10 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
>
<DialogPrimitive.Title className="sr-only">
{t('nav.search', 'Search sections')}
</DialogPrimitive.Title>
<div className="flex items-center gap-2 border-b border-border px-3.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('nav.searchPlaceholder', 'Jump to a section...')}
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:block">
Esc
</kbd>
</div>
<div className="max-h-80 overflow-y-auto p-1.5">
{flatItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('nav.searchEmpty', 'No results found')}
</p>
) : (
flatItems.map((item, i) => {
const showGroupHeader = item.group !== groupCursor;
groupCursor = item.group;
const Icon = item.icon;
return (
<div key={item.path}>
{showGroupHeader && (
<p className="mt-1.5 mb-1 px-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground first:mt-0">
{t(`nav.${item.group.toLowerCase()}`, item.group)}
</p>
)}
<button
type="button"
onMouseEnter={() => setActiveIndex(i)}
onClick={() => go(item.path)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm font-medium transition-colors',
i === activeIndex
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-accent'
)}
>
<Icon className="h-4 w-4 shrink-0" />
{t(`nav.${item.label.toLowerCase()}`, item.label)}
</button>
</div>
);
})
)}
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</DialogPrimitive.Root>
);
}

View File

@@ -0,0 +1,71 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
// react-markdown передаёт служебный проп `node` — вырезаем его, чтобы он не
// попал на DOM-элемент (иначе React warning про неизвестный атрибут).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function clean(p: any) {
const { node, ...rest } = p;
void node;
return rest;
}
/**
* Безопасный рендер markdown + встроенного HTML (описания приложений из README
* сторонних репозиториев). rehype-raw парсит HTML, rehype-sanitize вычищает
* потенциально опасные теги/атрибуты (защита от XSS).
*/
export function Markdown({ content, className = '' }: { content: string; className?: string }) {
return (
<div className={`text-sm leading-relaxed text-foreground/90 ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
h1: (p) => <h1 className="mt-4 mb-2 text-lg font-semibold" {...clean(p)} />,
h2: (p) => <h2 className="mt-4 mb-2 text-base font-semibold" {...clean(p)} />,
h3: (p) => <h3 className="mt-3 mb-1.5 text-sm font-semibold" {...clean(p)} />,
h4: (p) => <h4 className="mt-3 mb-1.5 text-sm font-medium" {...clean(p)} />,
p: (p) => <p className="my-2" {...clean(p)} />,
a: (p) => <a target="_blank" rel="noreferrer" className="text-primary hover:underline" {...clean(p)} />,
// Скриншоты из сторонних README часто лежат на CDN с hotlink-защитой
// (cloudinary и т.п.): по Referer они обрывают запрос (NS_BINDING_ABORTED)
// либо браузер режет ответ (OpaqueResponseBlocking). Снимаем реферер и
// мягко прячем не загрузившиеся картинки, чтобы не было «поломанных» иконок.
img: (p) => (
<img
loading="lazy"
referrerPolicy="no-referrer"
decoding="async"
className="my-2 max-w-full rounded-md border"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
{...clean(p)}
/>
),
ul: (p) => <ul className="my-2 list-disc space-y-1 pl-5" {...clean(p)} />,
ol: (p) => <ol className="my-2 list-decimal space-y-1 pl-5" {...clean(p)} />,
li: (p) => <li className="marker:text-muted-foreground" {...clean(p)} />,
pre: (p) => <pre className="my-2 overflow-x-auto rounded-md bg-muted/60 p-3 text-xs" {...clean(p)} />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
code: (p: any) => {
const { node, className: cn, children, ...rest } = p;
void node;
const inline = !cn?.includes('language-');
return inline
? <code className="rounded bg-muted px-1.5 py-0.5 text-xs" {...rest}>{children}</code>
: <code className={`${cn} text-xs`} {...rest}>{children}</code>;
},
blockquote: (p) => <blockquote className="my-2 border-l-2 border-border pl-3 text-muted-foreground" {...clean(p)} />,
table: (p) => <div className="my-2 overflow-x-auto"><table className="w-full text-xs" {...clean(p)} /></div>,
th: (p) => <th className="border-b px-2 py-1 text-left font-medium" {...clean(p)} />,
td: (p) => <td className="border-b px-2 py-1" {...clean(p)} />,
hr: (p) => <hr className="my-3 border-border" {...clean(p)} />,
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useId } from 'react';
import {
Area,
AreaChart,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
/** Presets shared by the instance and node graph views. */
export const TIMEFRAME_PRESETS = [
{ tf: 'hour', label: '1h' },
{ tf: 'day', label: '24h' },
{ tf: 'week', label: '7d' },
{ tf: 'month', label: '30d' },
];
/** HH:MM — the default tick format, adequate for hour/day ranges. */
export function fmtTime(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** DD.MM — used for week/month/year ranges where HH:MM repeats itself. */
export function fmtDate(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`;
}
/** Pick a tick formatter that stays readable for the given timeframe. */
export function tickFormatterFor(timeframe: string): (ts: number) => string {
return timeframe === 'hour' || timeframe === 'day' ? fmtTime : fmtDate;
}
/** Convert a datetime-local string to unix seconds. */
export function localToUnix(val: string): number | undefined {
if (!val) return undefined;
return Math.floor(new Date(val).getTime() / 1000);
}
/** Convert unix seconds to a datetime-local value string. */
export function unixToLocal(ts: number): string {
const d = new Date(ts * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export interface ChartCardProps {
title: string;
data: Array<Record<string, unknown>>;
dataKey: string;
color: string;
formatValue?: (v: number) => string;
unit?: string;
headerRight?: React.ReactNode;
/** Overrides the X axis tick format (defaults to HH:MM). */
tickFormatter?: (ts: number) => string;
}
/** Single area chart card — one metric series over time. */
export function ChartCard({
title,
data,
dataKey,
color,
formatValue,
unit,
headerRight,
tickFormatter,
}: ChartCardProps) {
// useId keeps the gradient unique even when two cards share a dataKey.
const gradientId = `grad-${dataKey}-${useId().replace(/:/g, '')}`;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{headerRight}
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="time"
tickFormatter={tickFormatter || fmtTime}
className="text-2xs fill-muted-foreground"
tick={{ fontSize: 10 }}
/>
<YAxis
className="text-2xs fill-muted-foreground"
tick={{ fontSize: 10 }}
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
width={55}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0, 0, 0, 0.65)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '6px',
fontSize: 12,
color: '#fff',
}}
labelStyle={{ color: '#fff' }}
itemStyle={{ color: '#fff' }}
formatter={(val: unknown) => [
val != null && typeof val === 'number' && formatValue
? formatValue(val)
: `${val ?? ''}${unit || ''}`,
title,
]}
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={1.5}
fill={`url(#${gradientId})`}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
import { Loader2, Minus, X, CheckCircle2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* Сворачивание модалок с долгими операциями (установка приложения, сборка
* золотого шаблона и т.п.): кнопка «Свернуть» в шапке диалога + плашка
* прогресса. Свёрнутые операции хранятся в глобальном сторе
* (stores/minimized-ops-store), а плашки рендерит MinimizedOpsTray в
* AppLayout — поэтому они переживают навигацию между страницами: прогресс
* опрашивает сам трей, независимо от смонтированности диалога.
*/
export function DialogMinimizeButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<Button
variant="ghost"
size="icon-sm"
className="absolute top-2 right-10"
onClick={onClick}
title={title}
>
<Minus className="h-4 w-4" />
<span className="sr-only">{title}</span>
</Button>
);
}
export type MinimizedStatus = 'idle' | 'running' | 'done' | 'failed';
interface PillProps {
title: string;
subtitle?: string | null;
progress?: number; // 0..100; undefined → без прогресс-бара
status: MinimizedStatus;
onRestore: () => void; // клик по плашке — развернуть диалог
onClose: () => void; // крестик — закрыть совсем
}
export function MinimizedTaskPill({ title, subtitle, progress, status, onRestore, onClose }: PillProps) {
return (
<div
role="button"
tabIndex={0}
className="w-72 cursor-pointer rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-colors hover:bg-accent/40"
onClick={onRestore}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onRestore(); }}
>
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />}
{status === 'done' && <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />}
{status === 'failed' && <XCircle className="h-4 w-4 shrink-0 text-destructive" />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
{subtitle && <div className="truncate text-xs text-muted-foreground">{subtitle}</div>}
</div>
<Button
size="icon-sm"
variant="ghost"
className="shrink-0"
onClick={(e) => { e.stopPropagation(); onClose(); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{typeof progress === 'number' && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full transition-all', status === 'failed' ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { MinimizedTaskPill, type MinimizedStatus } from '@/components/shared/MinimizableDialog';
import { useMinimizedOpsStore, type MinimizedOp } from '@/stores/minimized-ops-store';
import { useAppOperations, useGoldenTemplateStatus } from '@/hooks/use-appstore';
/**
* Глобальный трей свёрнутых операций (внизу справа). Монтируется в AppLayout,
* поэтому плашки переживают навигацию: каждая сама опрашивает прогресс своей
* операции по REST. Клик по плашке возвращает на страницу операции и
* разворачивает диалог (через navigation state — resumeInstall / openGolden).
*/
export default function MinimizedOpsTray() {
const items = useMinimizedOpsStore((s) => s.items);
if (!items.length) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{items.map((i) => (
i.kind === 'appstore-install'
? <InstallPill key={i.key} item={i} />
: <GoldenPill key={i.key} item={i} />
))}
</div>
);
}
function statusOf(opStatus: string | undefined): MinimizedStatus {
if (opStatus === 'running') return 'running';
if (opStatus === 'completed') return 'done';
if (opStatus === 'failed') return 'failed';
return 'idle';
}
function InstallPill({ item }: { item: MinimizedOp }) {
const { t } = useTranslation();
const navigate = useNavigate();
const remove = useMinimizedOpsStore((s) => s.remove);
const { data: ops, isError } = useAppOperations(item.installedAppId ?? 0, 3000);
const op = ops?.[0] ?? null;
// Стор персистентный (localStorage): после перезагрузки страницы плашка
// может ссылаться на уже удалённую установку — 404 от API убирает её сам.
useEffect(() => {
if (isError) remove(item.key);
}, [isError, remove, item.key]);
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const status = statusOf(op?.status);
return (
<MinimizedTaskPill
title={item.title}
subtitle={status === 'failed'
? (op?.error_text || t('appstore.install_failed'))
: (lastStep || t('appstore.installing'))}
progress={op ? op.progress : undefined}
status={status === 'idle' ? 'running' : status}
onRestore={() => {
remove(item.key);
navigate(`/appstore/${item.appId}`, { state: { resumeInstall: item.installedAppId } });
}}
onClose={() => remove(item.key)}
/>
);
}
function GoldenPill({ item }: { item: MinimizedOp }) {
const { t } = useTranslation();
const navigate = useNavigate();
const remove = useMinimizedOpsStore((s) => s.remove);
const { data: gstatus, isError } = useGoldenTemplateStatus(item.serverId, 3000);
const op = gstatus?.last_operation ?? null;
useEffect(() => {
if (isError) remove(item.key);
}, [isError, remove, item.key]);
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const status = statusOf(op?.status);
return (
<MinimizedTaskPill
title={item.title}
subtitle={status === 'failed'
? (op?.error_text || t('appstore.golden.failed', 'Ошибка сборки'))
: (lastStep || t('appstore.golden.working', 'Выполняется...'))}
progress={op ? op.progress : undefined}
status={status === 'idle' ? 'running' : status}
onRestore={() => {
remove(item.key);
navigate('/appstore', { state: { openGolden: item.serverId } });
}}
onClose={() => remove(item.key)}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More