diff --git a/instances/templates/instance.html b/instances/templates/instance.html index a4691e91..246eb0e3 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -198,7 +198,7 @@ $("#video_model_select option[value='" + video_model + "']").prop('selected', true); } }); -{% if can_open_console %} +{% if can_open_console and vdi_reachable %} $(document).ready(function () { // set vdi url $.get("{% url 'vdi_url' compute.id instance.name %}", function(data) { diff --git a/instances/templates/instances/access_tab.html b/instances/templates/instances/access_tab.html index 1169c82e..ba2f52ba 100644 --- a/instances/templates/instances/access_tab.html +++ b/instances/templates/instances/access_tab.html @@ -1,121 +1,86 @@ {% load i18n %} -
- +{% load vm_states %} +
+ {% vm_state instance.status as state %}
-
- -
- -
-
-

{% blocktrans with type=instance.console_type|upper %} This action opens a new window with a {{ type }} connection to the console of the instance.{% endblocktrans %} -

+
+

{% trans "Access" %} · {{ state.0 }} · {{ instance.console_type|upper }}

{% if not can_open_console %} -

{% trans "Only the owners of this instance and administrators can open its console." %}

- {% else %} - {% if instance.status == 1 %} - {# one window per VM: opening it again reloads it #} - - {% else %} - - {% endif %} - {% endif %} -
-
- {% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %} -
-

{% trans "Shut the VM down, or run it with its QEMU guest agent, and enter a new root password." %}

-
- {% csrf_token %} -
-
- -
+

{% trans "Only the owners of this instance and administrators can open its console." %}

+ {% elif instance.status == 1 or instance.status == 3 %} + {# one window per VM: opening it again reloads it; a paused VM shows its last screen #} +
+
+
- {% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %} - - {% else %} - - {% endif %} - -
-
- {% endif %} - {% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %} -
-

{% trans "Shut the VM down, or run it with its QEMU guest agent, and choose your public key. The key is added to root's keys." %}

-
- {% csrf_token %} -
-
- -
+ {% if vdi_reachable %} +
+ + +
+ - {% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %} - - {% else %} - {% endif %} - -
-
- {% endif %} - {% if instance.status == 1 and can_open_console %} -
-

{% trans "This action opens a remote viewer with a connection to the console of the instance." %}

- -

{% trans "To download console.vv file for virt-viewer." %}

- {% trans "Get console.vv" %} - -
+

+ {% trans "Console opens a window here." %} + {% if vdi_reachable %}{% trans "VDI opens your remote viewer; console.vv sets up virt-viewer." %} + {% else %}{% trans "A remote viewer cannot reach this console: it listens on the host only, or it is a serial one." %}{% endif %} +

+ {% else %} +

{% trans "Start the VM to open its console." %}

+ {% endif %} + {% if can_change and app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' %} +
{% csrf_token %} +
+
+ +

+ {% if guest_access_ready %}{% trans "The guest's root user gets this password." %} + {% elif instance.status == 1 %}{% trans "The guest agent is not connected: connect it, or shut the VM down." %} + {% else %}{% trans "Shut the VM down, or run it with its guest agent." %}{% endif %} +

+
+
+
+
+ + +
+
+
+
+
+ {% endif %} + {% if can_change and app_settings.SHOW_ACCESS_SSH_KEYS == 'True' %} +
{% csrf_token %} +
+
+ +

+ {% if not publickeys %}{% trans "You have no saved public key." %} {% trans "Add one in your profile." %} + {% elif guest_access_ready %}{% trans "One of your public keys joins root's keys in the guest." %} + {% elif instance.status == 1 %}{% trans "The guest agent is not connected: connect it, or shut the VM down." %} + {% else %}{% trans "Shut the VM down, or run it with its guest agent." %}{% endif %} +

+
+
+
+
+ + +
+
+
+
+
+ {% endif %}
- {% endif %} -
diff --git a/instances/tests/test_access_tab.py b/instances/tests/test_access_tab.py new file mode 100644 index 00000000..3ff7a437 --- /dev/null +++ b/instances/tests/test_access_tab.py @@ -0,0 +1,50 @@ +"""The Access tab: the console of a running or paused VM, a remote viewer +only where it can reach the console, and root's password and keys when the +VM can take them.""" + +from types import SimpleNamespace + +from django.template.loader import render_to_string +from django.test import SimpleTestCase +from lxml import html + +UUID = "abcdabcd-0000-0000-0000-000000000000" + + +def tab(status=1, vdi_reachable=True, guest_access_ready=True, publickeys=("laptop",)): + instance = SimpleNamespace(id=1, uuid=UUID, status=status, console_type="vnc") + page = render_to_string("instances/access_tab.html", { + "instance": instance, "compute": SimpleNamespace(id=1), "can_open_console": True, "can_change": True, + "app_settings": SimpleNamespace(SHOW_ACCESS_ROOT_PASSWORD="True", SHOW_ACCESS_SSH_KEYS="True"), + "publickeys": [SimpleNamespace(id=n, keyname=name) for n, name in enumerate(publickeys, 1)], + "vdi_reachable": vdi_reachable, "guest_access_ready": guest_access_ready, + }) + return html.fromstring(page) + + +class AccessTabTests(SimpleTestCase): + def test_a_running_or_paused_vm_opens_its_console(self): + for status, console in ((1, True), (3, True), (5, False)): + with self.subTest(status=status): + self.assertEqual(bool(tab(status=status).xpath("//button[@id='console_button']")), console) + + def test_a_remote_viewer_is_offered_only_where_it_reaches_the_console(self): + for reachable in (True, False): + with self.subTest(reachable=reachable): + doc = tab(vdi_reachable=reachable) + self.assertEqual(bool(doc.xpath("//a[@id='vdi_url'] | //input[@id='vdi_url_input']")), reachable) + self.assertEqual(bool(doc.xpath("//a[contains(@href, 'getvvfile')]")), reachable) + + def test_root_s_password_is_hidden_and_waits_for_a_vm_that_can_take_it(self): + for ready in (True, False): + with self.subTest(ready=ready): + doc = tab(guest_access_ready=ready) + (field,) = doc.xpath("//input[@name='passwd']") + self.assertEqual((field.get("type"), field.get("autocomplete")), ("password", "new-password")) + self.assertEqual(field.xpath("ancestor::fieldset")[0].get("disabled") is not None, not ready) + + def test_without_a_saved_key_the_tab_points_to_the_profile(self): + doc = tab(publickeys=()) + self.assertIsNotNone(doc.xpath("//select[@name='sshkeyid']/ancestor::fieldset")[0].get("disabled")) + self.assertEqual(doc.xpath("//select[@name='sshkeyid']/option"), []) + self.assertEqual(doc.xpath("//a[text()='Add one in your profile.']/@href"), ["/accounts/profile/"]) diff --git a/instances/tests/test_console_rule.py b/instances/tests/test_console_rule.py index 45f5970a..1ecf1b49 100644 --- a/instances/tests/test_console_rule.py +++ b/instances/tests/test_console_rule.py @@ -64,8 +64,16 @@ class CanOpenConsoleTestCase(TestCase): self.assertEqual(res.status_code, 200) self.assertIn(b"[virt-viewer]", res.content) # the VM found by its UUID; a console on all addresses is reached through the host - for line in (b"type=vnc", b"host=192.0.2.7", b"port=5900", b"title=cons-vm", b"password=secret"): + for line in (b"type=vnc", b"host=192.0.2.7", b"port=5900", b"title=cons-vm"): self.assertIn(line + b"\n", res.content) + # the password only with the permission the console page asks for, as there + self.assertNotIn(b"password=", res.content) + self.owner.user_permissions.add(Permission.objects.get(codename="passwordless_console")) + with patch("instances.models.wvmInstance") as wvm: + proxy = wvm.return_value.cached_reads.return_value.__enter__.return_value + proxy.get_console_passwd.return_value = "secret" + res = self.client.get(reverse("instances:getvvfile", args=[self.instance.id])) + self.assertIn(b"password=secret\n", res.content) def test_owner_can_get_vdi_url(self): self.client.force_login(self.owner) diff --git a/instances/tests/test_page_controls.py b/instances/tests/test_page_controls.py index 5a2bb72b..24626a20 100644 --- a/instances/tests/test_page_controls.py +++ b/instances/tests/test_page_controls.py @@ -87,7 +87,7 @@ class PageControls: found = { "console": bool(doc.xpath("//button[@id='console_button']")), "vv file": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:getvvfile", args=[pk]))), - "vdi": bool(doc.xpath("//*[@id='vdiconsole']")), + "vdi": bool(doc.xpath("//*[@id='vdi_url']")), "power": form("poweron") or bool(doc.xpath("//button[@formaction=$a]", a=reverse("instances:poweroff", args=[pk]))), "root password": form("rootpasswd"), "ssh key": form("add_public_key"), @@ -197,6 +197,14 @@ class PageControlsTestCase(PageControls, TestCase): ) self.assertEqual(bool(buttons), enabled) + def test_a_remote_viewer_only_for_a_console_beyond_the_host_s_loopback(self): + admin = get_user_model().objects.create_superuser("vdi_admin", "vdi@example.com", "x") + for listen, shown in (("0.0.0.0", True), ("192.0.2.5", True), ("127.0.0.2", False), ("::1", False)): + with self.subTest(listen=listen), patch("instances.models.Instance.console_listener_address", + new_callable=PropertyMock, return_value=listen): + self.controls(admin) + self.assertEqual('id="vdi_url"' in self.last_page, shown) + def test_the_power_tab_offers_the_state_s_actions_and_confirms_a_power_cut(self): admin = get_user_model().objects.create_superuser("power_admin", "pa@example.com", "x") owner = self.user("power_owner", owner={}) diff --git a/instances/views.py b/instances/views.py index 5b203e51..7bf69181 100755 --- a/instances/views.py +++ b/instances/views.py @@ -265,6 +265,10 @@ def _instance_page(request, pk, instance, compute): can_open_console = utils.can_open_console(request.user, instance) can_power = request.user.is_superuser or userinstance is not None power_actions = allowed_actions(request.user, instance, instance.status) + # a remote viewer on the user's computer reaches a VNC or SPICE console that listens beyond the host's loopback + vdi_reachable = instance.console_type in ("vnc", "spice") and not util.is_loopback(instance.console_listener_address) + # root's password and keys change in a shut-off VM's disk (gstfsd on the host), or through a running VM's agent + guest_access_ready = instance.status == 5 or (instance.status == 1 and instance.guest_agent_ready) # get_instance(perm_type="delete"): a template only for superusers and staff owners can_delete = request.user.is_superuser or bool( userinstance and userinstance.is_delete and (request.user.is_staff or not instance.is_template) @@ -2225,7 +2229,9 @@ def getvvfile(request, pk): response.writelines(f"host={proxy.host if listen == '0.0.0.0' else listen}\n") response.writelines(f"port={proxy.get_console_port()}\n") response.writelines(f"title={proxy.instance.name()}\n") - response.writelines(f"password={proxy.get_console_passwd() or ''}\n") + # as the console page: without the permission the viewer asks for the password + if request.user.has_perm("instances.passwordless_console"): + response.writelines(f"password={proxy.get_console_passwd() or ''}\n") response.writelines("enable-usbredir=1\n") response.writelines("disable-effects=all\n") response.writelines("secure-attention=ctrl+alt+ins\n")