diff --git a/README.md b/README.md index e9d2d139..728edf6f 100644 --- a/README.md +++ b/README.md @@ -769,6 +769,7 @@ What an update from an older version changes for operators and users: - Failed logins lock a user (django-axes). The limit and lock time are on the Settings page; unlock with `python3 manage.py axes_reset_username `. - Changing one's own password is the "Can change password" permission (per user or group; new users have it); `SHOW_PROFILE_EDIT_PASSWORD` is gone. - The admin's first-login password change survives losing `data/admin_password` (migration `accounts.0007`); run `migrate` as a user who can read that file. +- A new VM and each clone get a random VNC password of their own (clones no longer share their source's). The console page sends it for a user with **Open a console without the VM's console password**, which a new user has unless unticked; others are asked for it. Existing VMs keep theirs. - An `admin` account that still has the password `admin` (the first user of versions up to v2.0.0) must change it at its next login, and its API tokens are refused until then (migration `accounts.0013`). - A `settings.py` kept from an older version must match the template (apps, middleware, the SQLite `OPTIONS`, the REST framework, knox, axes and password settings): rebuild it from the template and carry your own values over, as step 2 of the update does. - The VNC console connects to `/novncd/` on the panel's own address through nginx, and the serial console to `/socket.io/`: they work on HTTPS and behind another proxy without settings. `WS_PUBLIC_PORT`, `WS_PUBLIC_HOST`, `WS_PUBLIC_PATH`, `WS_CERT` and `SOCKETIO_PUBLIC_HOST`/`_PORT`/`_PATH` are gone; remove them from `docker-compose.override.yml`, `/etc/default/webvirtcloud` or a kept `settings.py`, set `WS_HOST = "127.0.0.1"` in a kept `settings.py`, and close port 6080 in the firewall. diff --git a/admin/forms.py b/admin/forms.py index 1c526c3c..62b83b0f 100644 --- a/admin/forms.py +++ b/admin/forms.py @@ -6,6 +6,7 @@ from django.contrib.auth import password_validation from django.contrib.auth.forms import AdminPasswordChangeForm from django.contrib.auth.models import Group, User from django.core.exceptions import ValidationError +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from accounts.forms import UniqueEmailMixin @@ -124,9 +125,11 @@ class UserCreateForm(UserForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # New users may change their own password unless unticked here + # New users may change their own password and open their VMs' consoles + # (whose VNC password the console page sends) unless unticked here self.fields["user_permissions"].initial = Permission.objects.filter( - content_type__app_label="accounts", codename="change_password" + Q(content_type__app_label="accounts", codename="change_password") + | Q(content_type__app_label="instances", codename="passwordless_console") ) def clean(self): diff --git a/admin/tests/test_user_create.py b/admin/tests/test_user_create.py index 38a77d12..a741e744 100644 --- a/admin/tests/test_user_create.py +++ b/admin/tests/test_user_create.py @@ -1,6 +1,8 @@ """Creating a user in the admin pages: the password reaches the database only hashed, and the configured password validators apply as on the change forms.""" +import re + from django.contrib.auth.models import Group, Permission, User from django.db import connection from django.test import TestCase, override_settings @@ -36,6 +38,12 @@ class UserCreateTests(TestCase): self.assertEqual(written, []) self.assertTrue(User.objects.get(username="newbie").check_password(RAW)) + def test_a_new_user_may_change_the_password_and_open_consoles_unless_unticked(self): + # new VMs have a VNC password, which the console page sends only with passwordless_console + page = self.client.get(reverse("admin:user_create")).content.decode() + ticked = set(re.findall(r']*name="user_permissions"[^>]*value="(\d+)"[^>]*checked', page)) + self.assertEqual(ticked, {str(self.change_password.pk), str(Permission.objects.get(codename="passwordless_console").pk)}) + def test_groups_and_permissions_are_saved(self): group = Group.objects.create(name="uc-group") self.create(groups=[group.pk], user_permissions=[self.change_password.pk]) diff --git a/instances/jobs.py b/instances/jobs.py index 43536877..1a22ca17 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -884,7 +884,7 @@ class CreateInstance(Kind): nwfilter=data["nwfilter"], net_model="virtio" if data["net_model"] == "default" else data["net_model"], video=data["video"], - console_pass="", + console_pass="random", # without one, anyone reaching the VNC port is in mac=data["mac"], qemu_ga=data["qemu_ga"], add_cdrom=data["add_cdrom"], diff --git a/instances/tests/test_create_job.py b/instances/tests/test_create_job.py index 21c4649c..dbb25923 100644 --- a/instances/tests/test_create_job.py +++ b/instances/tests/test_create_job.py @@ -48,6 +48,8 @@ class CreateJobTestCase(TransactionTestCase): op.refresh_from_db() CreateInstance().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a"))) self.assertEqual(self.conn.create_instance.call_args.kwargs["uuid"], op.intent["uuid"]) + # a console password of its own: without one, anyone reaching the VNC port is in + self.assertEqual(self.conn.create_instance.call_args.kwargs["console_pass"], "random") self.assertTrue(Instance.objects.filter(uuid=op.intent["uuid"], name="api-vm").exists()) def test_the_api_takes_secure_boot_and_a_tpm(self): diff --git a/vrtManager/instance.py b/vrtManager/instance.py index e2ac1f4f..49177131 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -2398,6 +2398,10 @@ class wvmInstance(wvmConnect): raise ValueError(f"No MAC address for network interface {num}") net.find("mac").set("address", self.fix_mac(mac)) + # the clone's console gets a password of its own: one copied from the source would open both + for graphics in tree.findall("devices/graphics[@type='vnc']"): + graphics.set("passwd", util.randomPasswd(8)) # the length VNC allows + nvram = tree.find("os/nvram") if nvram is not None and nvram.get("type", "file") != "file": raise util.OperationError(gettext_noop("Cannot clone a VM whose NVRAM is not a file")) diff --git a/vrtManager/tests/test_clone_console.py b/vrtManager/tests/test_clone_console.py new file mode 100644 index 00000000..cfb01896 --- /dev/null +++ b/vrtManager/tests/test_clone_console.py @@ -0,0 +1,26 @@ +"""A clone's VNC console gets a password of its own: one copied from its +source (a template's, say) would open the source's and every other clone's.""" + +import unittest + +from vrtManager.instance import wvmInstance + + +def clone_graphics(passwd_attribute): + p = wvmInstance.__new__(wvmInstance) # no connection: only these calls + p.get_instances = lambda: [] + p._XMLDesc = lambda flags: f"""tplu + """ + tree, _ = p._clone_definition({"name": "copy"}, "abababab-0000-1111-2222-333333333333") + return tree.find("devices/graphics") + + +class CloneConsoleTestCase(unittest.TestCase): + def test_each_clone_gets_its_own_vnc_password(self): + passwords = {clone_graphics("passwd='shared12'").get("passwd") for _ in range(5)} + self.assertNotIn("shared12", passwords) + self.assertEqual(len(passwords), 5) + self.assertTrue(all(len(p) == 8 for p in passwords)) # the length VNC allows + + def test_a_source_without_a_password_gives_a_clone_with_one(self): + self.assertEqual(len(clone_graphics("").get("passwd")), 8)