- Runs only when TEST_LIBVIRT_HOST is set, so manage.py test on a
developer machine no longer creates VMs on the local libvirtd.
- Every VM and volume is named wvc-test-*, and volumes live in a
temporary wvc-test pool (/var/lib/libvirt/wvc-test) instead of the
default pool next to real images.
- Cleanup removes only prefixed domains and the test pool's volumes;
it also clears leftovers of an interrupted run before starting, and
tolerates domains that turned transient.
- tearDownClass fails if any domain or volume outside the test
namespace appeared, disappeared or was replaced.
- livetest gains create_volume/define_vm helpers and
test_live_dataloss.py, whose setUpClass opens the class transaction
last, so a failed libvirt connection cannot lock the test database
for later test classes.
The first live tests reproduce two known external-snapshot data losses
and are marked expectedFailure: reverting deletes a disk added after
the snapshot, and deleting the older of two snapshots deletes the
newer one's overlay.
The edit form posts net-*-<n> fields for NIC n and carries only the
new MAC. The backend read index 0 and looked the NIC up by the new MAC,
so editing a MAC detached nothing and attached a second NIC, and edits
of any NIC but the first were applied with empty values.
The form now also posts the current MAC (net-old-mac-<n>). The view
reads the submitted NIC's fields, validates the new MAC and reports
errors. The backend replaces the NIC with the old MAC, refuses an
unknown one, and on a failed attach restores the old NIC and raises
instead of reporting success.
The view crashed with a TypeError without a Referer header; it now
returns to the instance page, and a Referer from another site is
ignored.
- Flavor create/update/delete fell back to the nonexistent
instances:flavors URL; reverse() ran on every request, so each of
them raised NoReverseMatch. Fall back to instances:index.
- Creating a VM from XML called instances.utils.refresh_instance_database,
which does not exist; use utils.refr.
Both found by running the live libvirt suite.
- Pick the test VM by name; .first() could select any VM already on
the compute, and tearDownClass deletes the chosen VM's disks.
- Delete the test volume when setUpClass fails after creating it.
- test-vm.xml: no hard-coded emulator, machine alias q35 instead of
pc-q35-5.1, so it defines on RHEL qemu-kvm hosts too.
- Follow current behavior: power actions are POST-only, the create
form needs add_cdrom/add_input and VNC, cloning needs change
permission on the source VM.
- README: the tests read TEST_LIBVIRT_HOST/TYPE/LOGIN/PASSWORD;
WEBVIRTCLOUD_TEST_LIBVIRT_URI never existed.
- Matrix 3.10-3.13; pip cache keyed on the requirements.
- New steps: makemigrations --check, spectacular --validate
--fail-on-warn, check --deploy --fail-level ERROR, pip-audit of the
installed environment (the Django 4.2.30 advisories are ignored until
the Django 5.2 upgrade), and a Docker image build.
- The separate vrtManager unittest run is dropped; manage.py test
already runs those tests.
- CodeQL also runs on pull requests to master.
- Docker installs into a plain venv instead of using the distro's
python3-lxml and python3-libvirt; lxml>=6.1, since the distro's
lxml 5.2.1 has CVE-2026-41066.
- dev requirements add ruff and pip-audit.
Super-linter ran from the unpinned :latest image and, because of
branches-ignore: [master], never on pull requests to master. Ruff now
checks syntax errors and pyflakes rules (E9, F) on every push and PR.
Fixes the existing findings: unused imports and variables, an unused
duplicate import in instances/tests.py, and the star import in
accounts/views.py. settings-dev.py keeps its star import of the base
settings.
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.
Disk edits:
- edit_disk built a new <disk> from the form and replaced the old one,
losing what the form does not show (e.g. encryption and virtio
driver options); a bus change detached and re-attached the disk.
The disk of the persistent definition is now edited in place and the
domain defined once; a bus change picks a free device name and drops
the old address and virtio-only options. Pending persistent changes of
a running VM are kept.
- Clone omits empty disk devices, refuses a disk without a supported
source and destination, and checks the name before allocating storage.
Locks:
- Every VM change held its compute's exclusive lock, so one slow
operation blocked all VMs on that compute. A VM change now holds its
own VM lock plus a shared compute lock; reconciliation, migration and
cloning hold the compute exclusively (two clones of different VMs
must not pass the name check together).
- Quota-checked changes of one user (CPU, memory and disk resize,
clone) run one at a time under a per-user lock, also across computes,
so concurrent requests cannot all pass the check against the old
allocation. It is the outermost lock; superusers, who have no quota,
skip it.
- A busy lock answers 409 to the API and a message in the web UI. API
power and destroy actions return the view's error instead of success.
The upload session was recorded only after the first chunk had been
written, and a retried first chunk was refused when its part file was
missing. The session (with its upload id) is now written before the
part file is created, the part file is created by whichever chunk finds
none, and a missing part file is an error only after the first chunk.
An interrupted first chunk can be sent again without adopting an
unrelated file.
The forced change of the generated admin password was a session flag
set at login when the password still matched data/admin_password. A
new session after the file was gone bypassed it.
- UserAttributes.must_change_password (migration 0007) holds it; the
admin created with a generated password gets it, and a password
change clears it. ForcePasswordChangeMiddleware reads it.
- A system check (accounts.E001) stops the server unless the middleware
follows AuthenticationMiddleware; the README has the settings change.
- If the generated password cannot be stored privately, provisioning
stops without printing it and without creating the admin (one
transaction for the user and its attributes).
- The admin is provisioned on any real migrate run while no user
exists, so running migrate again after a failure creates it; flush
(plan=None) and rollbacks of accounts are skipped.
- The post_migrate receivers check the migration state for the models
they use, so a targeted migrate on a fresh database (e.g. migrate
appsettings) no longer fails with 'no such table'.
- 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.
The *migrations/* ignore rule is removed. appsettings, computes and
logs pin default_auto_field to AutoField, so makemigrations --check
reports no pending BigAutoField migrations and existing installs need
no schema change.
The Vagrant provisioning no longer runs makemigrations, which created
install-specific migration files that conflict with the committed
history; it only runs migrate.
- Add the public key to authorized_keys instead of replacing the file.
- Use AF_INET6 when GSTFSD_BIND_HOST is an IPv6 address (binding ::1
crashed with the AF_INET server).
- Close idle client connections after 30 s.
Tests run the real daemon against a small fake guestfs module over a
socket; this also exercises the earlier sendall() fix.
- 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.
A disk without driver/@type could not be edited: the empty format was
rejected, and edit_disk would have written type=''. An empty format is
now accepted and edit_disk leaves the type attribute out.
readQueryVariable('token', ...) preferred the unsigned host-uuid token
from the URL, which novncd rejects; use the signed token the view
renders, like the full page does.
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.
In Docker, migrate runs as root while data/ belongs to www-data, so a
planted data/admin_password symlink would make root overwrite its target.
Remove any existing path first and create the file with
O_CREAT|O_EXCL|O_NOFOLLOW (mode set via fchmod). install.sh's /tmp
fallback log is now created with mktemp instead of a fixed name.
- The serial console cannot work until socketiod is rewritten (it does
not understand the signed console token), so the README no longer
describes how to enable it; only the hardening line for existing
installs is kept. The reverse-proxy example no longer sets
SOCKETIO_PUBLIC_PORT.
- Move the users, roles and permissions reference to doc/permissions.md
and link it from the README.
Clone refused VMs that cloned fine before: a disk without a volume got a
None disk name, and a source image with characters such as a space or "@"
produced a derived name outside the allowed pattern. Disks without a
volume (which clone_instance does not copy) are no longer validated, and
derived disk names are sanitized to the allowed charset. Names typed by a
superuser are still validated strictly.
gstfsd only listens on loopback unless GSTFSD_BIND_HOST and
GSTFSD_ALLOW_REMOTE=1 are set; add commented environment lines for both
service definitions. gstfsd.conf now ends with a newline, which matters
because dev/libvirt-bootstrap.sh appends it to /etc/supervisor.conf.
The edit_volume bus change detached the disk and attached a new one built
as a file disk, so block and rbd disks lost their source and auth, and a
failed attach left the disk detached. Add wvmInstance.change_disk_bus,
which reuses the disk element and only changes <target> (dropping the old
bus address), and re-attaches the original disk if attaching fails.
edit_disk also rebuilt every disk with <source file=...>; it now keeps the
existing <source> and <auth> of block and network disks.
- Hash the root password with a random SHA-512 salt instead of the fixed
"$6$kgPoiREy".
- Talk to gstfsd through one helper with a 120 s socket timeout; an
unreachable daemon or a bad reply is an error message, not a 500.
- gstfsd refuses to listen on a non-loopback address unless
GSTFSD_ALLOW_REMOTE=1: it is an unauthenticated root service.
- gstfsd: encode replies (sendall(str) raised TypeError on Python 3, so
the success path never worked) and use octal chmod modes.
- README: what gstfsd does and how to allow it on a remote compute.
- The generated admin password is written to data/admin_password (mode
0600) instead of being printed, so it no longer ends up in the install
log or `docker logs`. If the file cannot be written it is printed as
before, so the admin is never locked out.
- webvirtcloud.sh no longer echoes the generated SECRET_KEY.
- install.sh creates the install log with mode 0600.
- user_block, user_unblock and admin_email_otp changed state on GET, so an
<img> tag could lock a user out or trigger OTP emails through an
admin's session. They now require POST; the buttons are CSRF forms.
- The console token cookie is set with httponly, SameSite=Lax and, on
HTTPS, secure. Only novncd reads it; the page gets the token directly.
compute_graph (text/javascript) and the machine type, disk bus, video
model and domain capability views (text/html) return JSON; send it as
application/json. create_instance_w1.html builds the chipset <option>
elements with .val().text() instead of string concatenation.
- edit_volume passed driver_type to attach_disk, which has no such
parameter, so every bus change failed after the disk was detached; pass
format_type.
- A disk without a driver type shows "None" in the edit form; keep the
disk's current format instead of rejecting it.
- Validate with fullmatch, so a trailing newline is no longer accepted,
and use one volume-name pattern for disk views and clone.
- 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.
change_options set is_template for any owner with is_change; the
"staff only" rule existed only as a disabled checkbox in the UI. A
non-staff owner could mark or unmark templates with a POST, and because a
disabled checkbox is not submitted, a non-staff owner who only edited a
template's title or description silently unmarked it, which lifted the
template-only restrictions.
Only change is_template for superusers and staff (for them an unchecked,
unsubmitted checkbox still means "not a template"); leave it alone for
everyone else.
is_staff does not reveal console settings or VNC passwords (fixed on
fix/wave0-staff-console-password), but staff owners with is_change may
also change VMs marked as templates. Superuser-only pages answer 403 to
everyone else, and the console page answers 403 both for a missing VM and
for a VM the user may not open.
The console settings form on the VM detail page renders the VNC password
(PasswordInput(render_value=True)) and was shown to every is_staff user.
A staff user with the global view_instances permission, or a staff owner
without is_vnc, could read the VNC password of every VM they could see.
Add instances.utils.can_manage_console (active superuser, or an active
owner with is_change and is_vnc), which matches what update_console
already enforced. The detail view only builds the form, and so only puts
the password into the page, when it returns True; the template and
update_console use the same rule.
Describe the access model: superuser, the global permissions
(view_instances as a read-only auditor role, clone_instances,
snapshot_instances, passwordless_console, change_password) and per-VM
ownership flags (is_change, is_delete, is_vnc), with a table of which
VM actions each combination allows.
The table describes the state after all Wave 0 branches are merged:
disk/media views are superuser-only (fix/wave0-disk-tenant-isolation) and
clone requires is_change except for templates (fix/wave0-clone-permission).
novncd accepted signed tokens without the user part, which skipped the
per-user access check, and accepted unsigned "host-uuid" tokens whenever
"test" appeared in sys.argv. The console view always signs
host:uuid:user_id, so anything else is now rejected with PermissionError,
and the user check always runs.
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.
clone only required "view" access, so a user with clone_instances and the
global view_instances permission could clone anyone's VM and read its
disks. It also copied every POST field into clone_data, which let the
client override the server-set disk_owner_uid/gid, and disk-<dev> values
went unvalidated into volume XML and os.path.join.
- Require change permission on the source VM, except for templates,
which any user who can view them may clone.
- Build clone_data from an allowlist: name, title and description for
everyone; disk names and MAC addresses only from superusers.
- For non-superusers, derive disk names (get_clone_disk_name) and MAC
addresses (DHCP or random) on the server; their form only sent the
source VM's own values, which could never work.
- Validate every MAC address (not only the first) and every disk name;
a missing MAC is an error message instead of a KeyError.
Several views returned json.dumps() output through HttpResponse, i.e. as
text/html, and guess_mac_address reflects the URL's vname into it
(reflected XSS). Templates filled the log table and volume dropdowns by
concatenating server data into HTML strings, and log messages contain
user-entered snapshot/disk/clone names (stored XSS that runs when a
superuser opens the Logs tab).
- Use JsonResponse in guess_mac_address, random_mac_address,
guess_clone_name, sshkeys, storages.get_volumes,
datasource.os_metadata_json and logs.vm_logs; sshkeys?plain=true is
served as text/plain.
- stats_tab.html builds log rows with .text(); instance.html and
create_instance_w2.html build <option> elements with .val().text().
socketiod does not validate the console token, keeps a single global PTY
and broadcasts its output to every connected client, so anyone who can
reach port 6081 can read and write a serial console that someone opened.
It is also broken for legitimate users, since console/views.py now writes
signed tokens that socketiod cannot parse.
Until it is rewritten, turn the serial console off by default:
- Add SERIAL_CONSOLE_ENABLED = False to the settings template; installs
whose settings.py lacks it are treated as disabled.
- The console view shows an error instead of the xterm page for pty
consoles while the setting is off.
- socketiod refuses to start unless SERIAL_CONSOLE_ENABLED is True.
- Supervisor: autostart=false for socketiod; drop -d (debug) from the
supervisor and systemd definitions; fix the systemd unit description;
conf/systemd/install.sh no longer suggests enabling socketiod.
- README: stop opening port 6081 and document how to enable the serial
console on a trusted network.
Disk and media mutation views (add_new_vol, add_existing_vol, edit_volume,
delete_vol, detach_vol, add_cdrom, detach_cdrom, mount_iso, unmount_iso)
were reachable by any owner with is_change, although the UI only shows the
Disk tab to superusers. An ordinary user could delete any volume on the
compute or attach arbitrary host files to their VM.
- Require superuser for all disk/media views, matching the UI.
- delete_vol resolves the volume from the VM's own disk XML by dev and
ignores the posted storage/name.
- Validate bus/cache/io/discard/detect_zeroes against the hypervisor's
lists, and format/serial/volume name against strict patterns.
- add_existing_vol only accepts volumes that exist in the selected pool.
- edit_volume only accepts a dev attached to the VM.
- vrtManager: escape all values interpolated into disk XML and pass dev
as an XPath variable in detach_disk/edit_disk.
- Rewrite purpose/features intro around libvirt as single source of truth
- Generate SECRET_KEY into data/secret_key instead of editing settings.py
- Consolidate local development setup into one flow using
--system-site-packages, settings-dev and runserver --nostatic
- Document the generated admin password instead of admin/admin
- Use the shared supervisor config on RHEL (adds missing socketiod)
and add supervisor setup on openSUSE
- Document SOCKETIO_PUBLIC_PORT and CSRF_TRUSTED_ORIGINS for proxies
- Point test setup at the dev virtualenv