- Surface a topbar indicator with a live count of active tasks
- Propagate exceptions from Proxmox LXC container creation instead of
swallowing them, and validate minimum password length
- Replace the Fedora catalog icon with the official logo artwork
Prevent edit/delete action buttons from overlapping with long template
names by using shrink-0 on icon and button containers with flex-1
min-w-0 on the text area. Make header and filter selects responsive
with flex-wrap and full-width on mobile breakpoints.
Add DNS name validation (RFC 952/1123) for VM, LXC and ISO instance
names on both backend and frontend. Proxmox rejects names with
underscores or special characters but the error was only surfaced as
a raw 400 from the API.
Also require Cloud-Init user and password fields for VM deploys
(highlighted when empty, Next button blocked), and fix numeric input
fields that showed "0" on clear and concatenated values like "04096".
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.
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.
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.
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.
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>
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.