The installer and the manual installation install four systemd units
(webvirt-cloud, webvirt-novncd, webvirt-socketiod, webvirt-worker) that
run as the web server's user with absolute paths and read
/etc/default/webvirtcloud. gunicorn, socketiod and the worker stop with
KillMode=mixed, so they wind down their own children. supervisor, its
installer code and the contributed systemd files go; the README gets
the update, rollback and a one-time move from supervisor, and the
installer refuses to run over a supervisor install. Docker keeps runit;
novncd's runit script is the one-line form of the others.
The xterm console never connected: socketiod expected a different token
format, its worker had no compute credentials, one global console was
shared by every client and its output broadcast to all of them, and the
Docker image did not run socketiod at all.
- console/tokens.py: one authorize() for noVNC and socketiod; the signed
token is the only credential, checked at connect time.
- socketiod: one pty worker per client (consolecallback with the
instance id), one session per VM, a session cap, input and resize
checked; the parent never opens libvirt. The engine.io same-origin
check is on.
- consolecallback: opens the console with the compute's credentials and
exits with a message when it cannot.
- Docker runs socketiod as a runit service; nginx proxies /socket.io/
with Host and the scheme for the origin check. Supervisor and systemd
start it as well; WEBVIRTCLOUD_SERIAL_CONSOLE=1 enables it.
- Tests: token and session unit tests, socketiod as a process, and a
live test that reads SeaBIOS from a wvc-test VM on the TCP and SSH
hosts.
Tests create and log in users for most test cases; Django's default PBKDF2 hash made that half of the suite's time (260 s -> 124 s locally with MD5). The fast hasher is set only when the command is manage.py test.
131 test modules sat next to the code they test; instances alone had
51 of them beside views.py and utils.py. They move into a
tests/ package per app, and the JavaScript checks into
webvirtcloud/tests/js. Each non-empty tests.py becomes tests/test_views.py;
the empty generated stubs are removed.
Imports of the app's own modules become absolute, paths built from
__file__ go one level up, the live helper is instances.tests.livetest,
and coverage omits */tests/*. The suite discovers the same 960 tests as
before.
django-qr-code was used for one thing: the QR code in the OTP e-mail,
as an inline SVG and a PNG. It brought pydantic, pydantic-core,
annotated-types and typing-inspection (about 10 MB) and, from 5.0 on,
Pillow. segno, which it used underneath, now draws both images with the
same options (error correction M, 4-module border, 1.8 mm SVG modules,
12 px PNG modules); the SVG, the PNG and the alt text are byte for byte
what django-qr-code 4.2 produced. account.html loaded the tag library
without using it.
The .vv console file is built from the VM found by its UUID, with the
console page's getters, instead of looking the VM up by name five times;
a console without a listen address or password now gives 127.0.0.1 and
an empty password instead of "None". Removed methods nothing calls: the
by-name getters and power methods of wvmInstances, managed save, the vCPU
mode getter (it read the wrong attribute), the old NIC editor and its
bridge lookup. CD-ROM media and NIC hotplug use _definitions() like the
other device edits, the disk editor takes the bus prefix from
util.vol_dev_type, and an unreadable disk is logged, not printed.
Each stats answer carries a signed sample of the counters, bound to the VM,
its compute and UUID. The next poll sends it back and the rates are the
averages since then; a sample from another run of the VM (domain ID),
another host CPU count or device list, or older than 30 s falls back to
one second as before. Windows are measured on the monotonic clock.
Now that a poll is cheap, the Stats tab polls every 5 s like the host
graph, the first time as soon as it opens (pollWhileVisible takes the
delay of the first request).
A counter the host reports as unsupported (-1) or that went back gives no
value instead of 0 or a jump; a NIC without a target keeps its chart, a
pool volume disk is measured, and a disk or NIC attached after the page
loaded no longer stops the other charts. Disk rates are MB/s (they were
MiB/s labelled Mb/s), network Mbit/s; the CPU tooltip names the window.
Without instances.snapshot_instances (snapshots) or is_vnc (console
settings) the request was accepted and silently did nothing, although
the documented answer for a missing right is 403. delete_external_snapshot
also asked libvirt for the VM state before checking the permission.
Redirects to #access (root password, SSH key) and other top-level panes
opened nothing, and some sub-tab ids did not exist. static/js/instance_tabs.js
opens the pane and the tab that holds it from the page structure, and
opens Settings on its first section, without leaving the current tab;
guest OS info is loaded whenever it is the section opened. The bottom bar linked to
host pages, which only administrators may open, and showed host names to
everyone. A stray </li> is removed; the page's inline scripts and the tab
logic are checked with Node.
Power cycle force-stopped and started a template, which power on refuses.
A non-staff owner with is_delete could delete a template that the
template rule otherwise leaves to superusers and staff owners;
get_instance(perm_type="delete") now refuses it, and the page offers
Destroy by the same rule.
The documented rule held only for snapshots: an owner with is_change could
still resize a template, set its root password and SSH keys, change its
options and console settings. get_instance(perm_type="change") now
refuses a template to owners who are not staff, which covers every change
view and the API; the per-view snapshot checks it replaces are removed,
and the page offers those controls by the same rule. Viewing, the console,
power (a no-op on a template) and cloning are unchanged.
The page showed controls the backend then refused: power to global
viewers, the console, virt-viewer and VDI controls and the root password
and SSH key forms to viewers, resize to staff, per-vCPU hotplug, video
model and guest agent to non-superusers, clone and options by the clone
permission alone, snapshots without change permission. The page now asks
the views' own rules (owner, is_change, clone and snapshot permissions,
the template rules). OS info, opened from the header, is there for every
viewer; the XML editor and tab restoring after a redirect no longer throw
when a pane is absent; Settings opens on the first section the user has.
The authorization matrix also covers a template VM, the template clone
rule and the template snapshot rule.
OTP (OTP_ENABLED) was enforced only by the HTML login form, but two other
doors let an enrolled user authenticate with a password and no token, so a
leaked password defeated the second factor. Both were reproduced with a
confirmed TOTP device: /api-auth/login/ (DRF's browsable-API login) created a
site-wide session, and DRF's default BasicAuthentication let every /api/v1/
endpoint accept HTTP Basic credentials.
The api-auth/ include is removed, so the only interactive login is the
OTP-aware form at /accounts/login/; the browsable API still works with the
session that form sets. REST_FRAMEWORK now pins DEFAULT_AUTHENTICATION_CLASSES
to SessionAuthentication only, so the API requires a session and no longer
accepts a password over HTTP Basic. A session therefore implies the OTP factor
was met. Password-based programmatic API access is intentionally dropped.
The authorization matrix drops its rest_framework:login/logout allowlist
entries (the routes are gone). New tests assert the browsable login route is
removed, HTTP Basic is rejected on the API, Basic is not a default auth class,
and a session still reaches the API. Verified live with OTP_ENABLED that
/api-auth/login/ is 404 and an API Basic request is 403.
The VM page polls the VM state every 5 s and reloads when it differs
from the state the page was rendered with. Since the polling moved to
pollWhileVisible, that state was kept in a top-level "var status",
which is not a page variable but the browser's window.status. Where the
browser does not keep a value there, the poll compared the answer with
"" and reloaded the page every 5 s, back to its first tab.
The variable is now vm_status. A Node test runs the page's script with
a window.status that keeps nothing.
Nothing limited failed logins: a password could be guessed at the speed
the server answers. django-axes (8.3.1, Django 5.2, Python 3.10-3.13)
now counts them.
- A user is locked for the address the failed logins come from
(username and IP together), so an attacker cannot lock someone out
from elsewhere and users behind one NAT address do not share a lock.
A successful login resets the count. axes sits in front of every
authentication: the login page with or without OTP, LDAP and the API.
- The number of failed logins (default 5) and the lock time in minutes
(default 15) are on the Settings page (LOGIN_FAILURE_LIMIT,
LOGIN_LOCKOUT_MINUTES, added by migration appsettings.0014) and apply
at once; an invalid value falls back to the default. Unlock with
"manage.py axes_reset_username <username>".
- Behind the bundled nginx every request comes from 127.0.0.1; the
client address is read from X-Real-IP, which nginx sets, but only for
requests from a loopback address or from LOGIN_TRUSTED_PROXIES, so a
forged header does not escape a lock.
settings.py.template gets the app, middleware, backend and AXES_*
settings; README tells existing installations what to copy. Tests that
call client.login now pass a request, which the axes backend needs.
The new tests fail with axes disabled.
When a VM action failed because its host could not be reached (the
connection could not be opened, or it dropped during the call), the
error page showed libvirt's connection error to any user: it names the
host and how it is reached, e.g. "ssh: connect to host 10.x port 22".
The lists and the VM page already keep that to administrators.
- Opening a connection now fails with ConnectionFailed (a subclass of
OperationError). A dropped connection is recognized by its error
domain and code (RPC/remote/SSH layer with a transport code, measured:
a keepalive timeout is VIR_FROM_RPC/VIR_ERR_INTERNAL_ERROR); an
operation the daemon does not support (VIR_ERR_NO_SUPPORT) is not
treated as one.
- ExceptionMiddleware (pages and API) and the views that report the
error themselves (snapshot, revert, disk deletion, clone) show it to
superusers and view_instances users, and to everyone else "The host
of this virtual machine cannot be reached right now." Errors of the
operation itself ("domain is not running") are shown as before.
Checked on the test setup: powering on a VM of an unreachable compute
shows the owner the generic text and the administrator the error. The
tests fail on the previous code.
django-bootstrap-icons serves icons from static/icon_cache and fetches a
missing one from the CDN while the page renders; a server without
internet access shows an error text instead of the icon.
- copy (Clone on the instance lists), envelope-open ("Lost OTP?" page)
and save (VM settings, network page) were not cached; they are now,
fetched with django-bootstrap-icons itself.
- The user and group lists asked for 'exclamation-triangle ' with a
trailing space, an icon that does not exist; the warning icon never
showed.
A test checks that every {% bs_icon %} in the templates has its cache
file; it fails on the previous files.
The VM page polls the VM state every 5 s and, while its Stats tab is
open, the statistics every 10 s; the compute overview polls the host
graph every 5 s. All used setInterval:
- A new request went out on schedule while the previous one was still
open. When a host goes silent, a stats request waits up to 30-45 s,
so each open page held several sync workers at once.
- Each time the Stats tab was shown, the page made new charts and a
new interval without stopping the old ones, and polling went on on
the other tabs of the page.
- Hidden pages kept polling at full rate.
Measured in a browser against a test server (old code / new): Stats tab
shown 4 times, 31 s on it: 12 / 2 requests; 31 s on another tab of the
page: 12 / 0; page hidden for 31 s: 15 / 0, and the new code asks once
right after the page is shown again; with 25 s answers, at most 15 / 1
requests open at once.
static/js/poll.js repeats a request while the page is visible: the
next one 5 or 10 s after the previous answer, none while hidden, one at
once when shown again. The Stats tab makes its charts once and polls
only while shown. The answer handler runs after the poller's own
bookkeeping, so an error in it (a chart for a disk added meanwhile)
does not stop the polling. A Node test of poll.js runs with the test
suite when node is installed.
The OpenStack-style cloud-init endpoints (index, meta_data.json,
user_data) are fetched by booting VMs, which do not log in. Since the
login-required middleware they redirected to the login page, so the
datasource has not worked. They are login_not_required now; they expose
a hostname and the public SSH keys of a VM's owners.
A VM is found by the reverse DNS name of the client address, and VM
names are not unique (only compute and UUID are). user_data merged the
owners of every VM with that name, so a user who named their VM like
another user's VM got their key into that VM at its next cloud-init
run. Keys are now given only when exactly one VM has the name.
The client address is the last X-Forwarded-For entry, which nginx in
the default setup appends; gunicorn listens on 127.0.0.1 only. The bare
except around the reverse lookup is narrowed to OSError.
SHOW_PROFILE_EDIT_PASSWORD was replaced in 2020 by the change_password
permission, settable per user or group. A post_migrate hook applied the
old setting to all users so existing installs kept their behavior, and
asked to remove the setting afterwards; but the settings template still
set it to True. Every migrate, so every upgrade, therefore granted the
permission to all users again and undid an admin's per-user or
per-group choice (with False it would have revoked it from everyone).
- Migration accounts.0008 applies the setting one last time, if a
settings.py still has it; the post_migrate hook is gone.
- New users get the permission when they are created (any path,
including LDAP); the admin user form ticks it and saves the admin's
choice afterwards.
- The template no longer sets SHOW_PROFILE_EDIT_PASSWORD; a check
(accounts.W001) asks to remove it from existing settings, and the
README upgrade notes say so.
The test that removes the permission and runs migrate fails on the
previous code. An upgrade from master with users was simulated: the
setting is applied once, and a permission removed afterwards stays
removed through the next migrate.
The web app (wsgi.py), novncd and socketiod set SIGCHLD to SIG_IGN at
import. The kernel then reaps every child at once, so subprocess loses
the exit status and a failing command reports 0, in the server and in
anything else run in that process: the test suite had to reset the
signal in two tests.
It was added in 2023 against one defunct bash per gunicorn worker and
per novncd. That bash came from the settings template of the time,
which ran .dec_ldap_pwd.sh with Popen and never waited for it; the
template stopped doing that in 561fedf. A settings.py still holding
that line leaves at most one zombie per process, not a growing number.
- wsgi.py, novncd, socketiod: the global SIG_IGN is removed. subprocess
reaps its own children, websockify installs its own SIGCHLD reaper
for novncd's connection processes, and novncd's SSH tunnels wait for
their ssh process.
- socketiod: the pty child execs consolecallback instead of running it
and then returning into the server's code; disconnect reaps that
child (waitpid) instead of any child (wait).
- The settings template drops its unused subprocess import; the two
test workarounds are removed.
New tests load the web app and novncd in a fresh interpreter and check
that SIGCHLD is untouched and a failing command still returns 1; both
fail on the previous code. A patched novncd in the test container
served five connections and left no child behind.
Web views:
- 34 views returned to request.META["HTTP_REFERER"] + "#tab": without
a Referer they failed with a TypeError (500), and a Referer from
another site was followed (open redirect). They return through
_back(), which keeps a same-site Referer and otherwise opens the VM
page at the tab. get_safe_redirect uses the same check. Rejected disk
options also return to the VM's disks tab instead of the VM list.
- migrate_instance returned silently when the target was the current
compute or unreachable, so the web view logged "Instance is migrated"
and the API answered "instance migrate is started". It now raises;
the view reports the error and logs only a completed migration, and
an unknown compute id is a 404 instead of a 500. The automatic
migration after a clone skips a target that is the current compute.
- add_owner and del_owner failed with a 500 without their ids, add_owner
also when the user was already an owner, and del_owner deleted the
given ownership record even when it belonged to another VM. Ids are
validated, a record of another VM is a 404, and a repeated owner is
reported.
- Broad handlers that hid programming errors are narrowed: clone catches
libvirt, value and OS errors (a lock timeout included), ownership
lookups catch DoesNotExist. The DRBD status no longer needs its bare
except: the VM name is escaped in the regex, which was the only thing
that could fail there.
API:
- A libvirt error under /api/ is JSON with status 400, and a lock
timeout 409, instead of the HTML error page. This lives in the
existing ExceptionMiddleware, so existing settings need no change.
- Volume create/delete on an inactive pool answer 409, and invalid
volume data 400 with the serializer errors, instead of 200.
The new tests cover every view that returns with _back() without a
Referer and with a foreign one, the migrate failures on web and API,
the owner ids, the API error format and the volume statuses; nine of
the ten fail on the previous code.
Comments, docstrings and live test names cited item IDs of a roadmap
that is not part of the repository. They now say what is meant, or
nothing where the sentence stands without them. Test assertions and
behavior are unchanged.
The sync deleted the Instance of every VM missing from its compute, and
its owners and rights went with it. A VM moved with virsh, or undefined
and defined again, came back with no owner.
Now, when a VM disappears from a compute:
- if the same UUID is recorded on another compute, it moved: its owners
and template flag go there;
- otherwise they are kept in an InstanceTombstone and restored when the
UUID appears again within INSTANCE_OWNERSHIP_RETENTION_DAYS (30).
A UUID recorded on two computes at the same time is a copy and gets no
owners. VMs deleted through WebVirtCloud leave no tombstone.
django-login-required-middleware is dropped; Django 5.1+ ships the same
middleware. The login page is already marked login_not_required by
Django; email_otp gets the decorator, which replaces
LOGIN_REQUIRED_IGNORE_VIEW_NAMES. The datasource still requires login.
Existing settings.py files still name the old middleware. A system
check (accounts.E002) stops migrate and the server until MIDDLEWARE
uses Django's middleware, instead of an ImportError at startup; the
README has the steps.
A new matrix test walks every URL anonymously: everything outside a
short allowlist (login pages, the OpenAPI docs) sends the visitor to
the login page, and API views deny with 401/403. It passed with the old
middleware first and passes unchanged now.
Django 4.2 reached end of life on 2026-04-07.
- Django==5.2.17; django-login-required-middleware 0.9.0 keeps working
(the authorization matrix passes).
- USE_L10N removed from the settings template.
- Log out is a POST form in the navbar; Django 5 removed GET logout.
- pip-audit in CI no longer ignores the Django 4.2.30 advisories.
- README: Django 5.2, backup step and upgrade notes for existing
settings and GET logout callers.
python -Wd manage.py test shows no Django deprecation warnings. The live
libvirt suite passes on 5.2 and on 4.2 against a RHEL qemu-kvm host.
The matrix client swallowed exceptions, so a view that crashed counted
as "denied". Requests now raise; a 500 is accepted only when it is the
fixture's deliberate libvirt connection failure, which the exception
middleware marks in the template context. Requests carry a Referer, and
add_public_key gets a real key, so the views run their normal path.
- Routers use path converters (use_regex_path=False): computes, instances
and flavors take <int:...> ids, so non-numeric ids get 404 and
drf-spectacular derives the parameter types itself.
- Each method declares what it really returns: detail serializers,
a shared StatusSerializer for status-only replies, no request body for
actions. Instance properties used by the details serializer get return
type hints.
- The create-instance API drops the firmware field. Its only accepted
value was "" (BIOS), which produced an empty, invalid enum, and the
UEFI branch behind it was unreachable. Clients that still send it are
unaffected; DRF ignores unknown fields.
The schema now passes spectacular --validate --fail-on-warn; a test
keeps it that way.
app_active and view_active read request.resolver_match, which is None
when no URL matched, so the navbar raised AttributeError and the 404
became a 500.
- instances/test_xss.py: scan all templates for .html(, innerHTML = and
append('<...') and require each line to be in a reviewed allowlist, so
new sinks fail the test (the exact-string tests only caught the lines
they named).
- Authorization matrix: fill the datasource "version" parameter with
"latest" instead of "x". The datasource views then answer 200 to any
logged-in user; they are listed in the allowlist as known F-14.
While data/admin_password exists and a user's password still matches it,
the login sets a session flag (checked once at login, not per request).
ForcePasswordChangeMiddleware then sends that user to the password-change
page for every view except change password, login and logout. Setting a
new password clears the flag and deletes data/admin_password. No schema
change; if ADMIN_PASSWORD was set, there is no file and nothing is forced.
- Comment out the nginx /socket.io/ location: a firewall on 6081 does not
protect a console that nginx proxies on 80/443.
- Default SOCKETIO_HOST to 127.0.0.1.
- README: short enable steps and the changes existing installs need.
- test_socketiod_disabled writes its settings module to a temporary
directory instead of the source tree.
For six roles (global view_instances with and without is_staff, and VM
owners with no flags, is_change, is_delete, and is_change+is_vnc as staff)
walk every URL and compare the reachable set with the expected one in both
directions, so both privilege leaks and lost access fail the test. Each
request runs in a rolled-back transaction so destructive views do not
affect the following requests. The expectations mirror the roles table in
README.md and depend on all Wave 0 fixes, so the test lives on the
integration branch.
Add webvirtcloud/test_authorization_matrix.py, which walks every named URL
and checks that a user without any VM/compute relation only reaches an
explicit allowlist, and that the global view_instances permission does not
open consoles. It found:
- /computes/<id>/... views without any check (compute_graph,
get_compute_*, get_dom_capabilities, storages.get_volumes): the whole
computes URL namespace is now wrapped with superuser_only, so a view
added there later cannot be exposed by a missing decorator.
- guess_mac_address (leaks dhcpd.conf host/MAC pairs), random_mac_address
and guess_clone_name: superuser-only, like the buttons that use them.
- check_instance: cross-tenant VM name lookup with no caller; removed.
- getvvfile and get_vdi_url: reachable with only view_instances, and the
.vv file contains the VNC password.
Console access now follows one rule, instances.utils.can_open_console:
an active superuser or an active owner of the VM. The global
view_instances permission is read-only. It is used by the console page,
novncd, getvvfile and get_vdi_url (getvvfile and the VDI tab are kept for
virt-viewer users). The console page answers 403 instead of 500 when
access is denied, with the same answer for a missing VM.