mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(instances): a VM's access in one strip, its console password kept to its rule
The Access tab hid the console, the root password, the SSH key and the remote viewer behind four sub-tabs. The owner chose a compact layout from three rendered options: the state, a strip with the console and the remote viewer, then two rows for root's password and key, each saying why it waits when it does. Codex reviewed access end to end; these are fixed: - console.vv carries the console password only for users with the passwordless console permission, as the console page passes it; it gave it to every user who may open the console. - The root password field is a labelled password input, not plain text with a 24-character limit. - Without a saved public key the key row is disabled and points to the profile, instead of offering "None". - A paused VM's console opens, as the console view allows. - The remote viewer address and console.vv show only for a VNC or SPICE console that listens beyond the host's loopback; a viewer on the user's computer cannot reach the others, and a serial console gave a pty://…:None address.
This commit is contained in:
parent
efb77dd866
commit
60f12cefa1
6 changed files with 152 additions and 115 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,121 +1,86 @@
|
|||
{% load i18n %}
|
||||
<div class="tab-pane" id="access" role="tabpanel" aria-label="Instance access options">
|
||||
<!-- Nav tabs -->
|
||||
{% load vm_states %}
|
||||
<div class="tab-pane" id="access" role="tabpanel" aria-label="{% trans "Instance access options" %}">
|
||||
{% vm_state instance.status as state %}
|
||||
<div class="card">
|
||||
<div class="card-header bg-transparent pb-0">
|
||||
<ul class="nav nav-underline small" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#vnconsole" type="button" role="tab" aria-controls="vnconsole" aria-selected="true">
|
||||
{% trans "Console" %}
|
||||
</button>
|
||||
</li>
|
||||
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#rootpasswd" type="button" role="tab" aria-controls="rootpasswd" aria-selected="false">
|
||||
{% trans "Root Password" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sshkeys" type="button" role="tab" aria-controls="sshkeys" aria-selected="false">
|
||||
{% trans "SSH Keys" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if instance.status == 1 and can_open_console %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#vdiconsole" type="button" role="tab" aria-controls="vdiconsole" aria-selected="false">
|
||||
{% trans "VDI" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content card-body">
|
||||
<div role="tabpanel" class="tab-pane active" id="vnconsole">
|
||||
<p>{% blocktrans with type=instance.console_type|upper %} This action opens a new window with a {{ type }} connection to the console of the instance.{% endblocktrans %}
|
||||
</p>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary mb-2">{% trans "Access" %} · {{ state.0 }} · {{ instance.console_type|upper }}</p>
|
||||
{% if not can_open_console %}
|
||||
<p>{% trans "Only the owners of this instance and administrators can open its console." %}</p>
|
||||
{% else %}
|
||||
{% if instance.status == 1 %}
|
||||
{# one window per VM: opening it again reloads it #}
|
||||
<button type="button" class="btn btn-primary float-end" id="console_button"
|
||||
data-url="{% url 'console' compute.id instance.uuid %}" data-window="console-{{ instance.uuid }}"
|
||||
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')">{% trans "Console" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-primary float-end disabled">{% trans "Console" %}</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %}
|
||||
<div role="tabpanel" class="tab-pane" id="rootpasswd">
|
||||
<p>{% trans "Shut the VM down, or run it with its QEMU guest agent, and enter a new root password." %}</p>
|
||||
<form action="{% url 'instances:rootpasswd' instance.id %}" method="post"
|
||||
role="form" aria-label="Add root password to instance form">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<input type="text" class="form-control-lg" name="passwd"
|
||||
placeholder="{% trans "Enter Password" %}" maxlength="24">
|
||||
</div>
|
||||
<p class="mb-0">{% trans "Only the owners of this instance and administrators can open its console." %}</p>
|
||||
{% elif instance.status == 1 or instance.status == 3 %}
|
||||
{# one window per VM: opening it again reloads it; a paused VM shows its last screen #}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-auto">
|
||||
<button type="button" class="btn btn-primary w-100" id="console_button"
|
||||
data-url="{% url 'console' compute.id instance.uuid %}" data-window="console-{{ instance.uuid }}"
|
||||
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')">{% trans "Console" %}</button>
|
||||
</div>
|
||||
{% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %}
|
||||
<input type="submit" class="btn btn-primary float-end" name="rootpasswd"
|
||||
value="{% trans "Reset Root Password" %}">
|
||||
{% else %}
|
||||
<button
|
||||
class="btn btn-primary float-end disabled">{% trans "Reset Root Password" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %}
|
||||
<div role="tabpanel" class="tab-pane" id="sshkeys">
|
||||
<p>{% 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." %}</p>
|
||||
<form action="{% url 'instances:add_public_key' instance.id %}"method="post"
|
||||
role="form" aria-label="Add public key to instance form">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<select name="sshkeyid" class="form-select form-select-lg">
|
||||
{% if publickeys %}
|
||||
{% for key in publickeys %}
|
||||
<option value="{{ key.id }}">{{ key.keyname }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="None">{% trans "None" %}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
{% if vdi_reachable %}
|
||||
<div class="col-12 col-md">
|
||||
<label for="vdi_url_input" class="visually-hidden">{% trans "Remote viewer address" %}</label>
|
||||
<input id="vdi_url_input" class="form-control" type="text" readonly>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto d-flex flex-wrap gap-2">
|
||||
<a href="#" class="btn btn-secondary" id="vdi_url">{% trans "VDI" %}</a>
|
||||
<a href="{% url 'instances:getvvfile' instance.id %}" class="btn btn-secondary">{% trans "Get console.vv" %}</a>
|
||||
</div>
|
||||
{% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %}
|
||||
<input type="submit" class="btn btn-primary float-end" name="addpublickey"
|
||||
value="{% trans "Add Public Key" %}">
|
||||
{% else %}
|
||||
<button class="btn btn-primary float-end disabled">{% trans "Add Public Key" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if instance.status == 1 and can_open_console %}
|
||||
<div role="tabpanel" class="tab-pane" id="vdiconsole">
|
||||
<p>{% trans "This action opens a remote viewer with a connection to the console of the instance." %}</p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="input-lg disabled form-control" disabled id="vdi_url_input" />
|
||||
<a href="#" class="btn btn-primary" id="vdi_url">{% trans "VDI" %}</a>
|
||||
</div>
|
||||
<p>{% trans "To download console.vv file for virt-viewer." %}</p>
|
||||
<a href="{% url 'instances:getvvfile' instance.id %}" class="btn btn-primary float-end">{% trans "Get console.vv" %}</a>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
<p class="small text-body-secondary mt-2 mb-0">
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="mb-0">{% trans "Start the VM to open its console." %}</p>
|
||||
{% endif %}
|
||||
{% if can_change and app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' %}
|
||||
<form action="{% url 'instances:rootpasswd' instance.id %}" method="post" class="mt-3">{% csrf_token %}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="root-passwd" class="form-label mb-0">{% trans "Reset root password" %}</label>
|
||||
<p id="root-passwd-help" class="small text-body-secondary mb-0">
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<fieldset aria-describedby="root-passwd-help"{% if not guest_access_ready %} disabled{% endif %}>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<input id="root-passwd" name="passwd" type="password" class="form-control" autocomplete="new-password" required>
|
||||
<button type="submit" name="rootpasswd" class="btn btn-secondary flex-shrink-0">{% trans "Reset Root Password" %}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_change and app_settings.SHOW_ACCESS_SSH_KEYS == 'True' %}
|
||||
<form action="{% url 'instances:add_public_key' instance.id %}" method="post" class="mt-3">{% csrf_token %}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="root-sshkey" class="form-label mb-0">{% trans "Add SSH key for root" %}</label>
|
||||
<p id="root-sshkey-help" class="small text-body-secondary mb-0">
|
||||
{% if not publickeys %}{% trans "You have no saved public key." %} <a href="{% url 'accounts:profile' %}">{% trans "Add one in your profile." %}</a>
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<fieldset aria-describedby="root-sshkey-help"{% if not publickeys or not guest_access_ready %} disabled{% endif %}>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<select id="root-sshkey" name="sshkeyid" class="form-select">
|
||||
{% for key in publickeys %}<option value="{{ key.id }}">{{ key.keyname }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" name="addpublickey" class="btn btn-secondary flex-shrink-0">{% trans "Add Public Key" %}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
50
instances/tests/test_access_tab.py
Normal file
50
instances/tests/test_access_tab.py
Normal file
|
|
@ -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/"])
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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={})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue