diff --git a/README.md b/README.md
index bb171f27..578d52b5 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,8 @@ A compute node is a KVM host the panel manages over libvirt.
It listens on 127.0.0.1:16510 only, which serves a panel on the same host. For a remote panel, run `sudo systemctl edit webvirt-gstfsd`, add `[Service]` and `Environment=GSTFSD_BIND_HOST=0.0.0.0 GSTFSD_ALLOW_REMOTE=1`, and restart it. It is unauthenticated: allow TCP 16510 only from the panel's IP.
+ A running VM gets them through its QEMU guest agent instead, without gstfsd. On RHEL-family guests, SELinux lets the agent add SSH keys only after `sudo setsebool -P virt_qemu_ga_manage_ssh on` inside the guest.
+
A compute that ran gstfsd under supervisor: remove its program first (`/etc/supervisor/conf.d/gstfsd.conf` on Debian / Ubuntu, `/etc/supervisord.d/gstfsd.ini` on RHEL / openSUSE), run `sudo supervisorctl update`, and remove supervisor once nothing else uses it.
4. Add the compute on the panel's Computes page.
diff --git a/instances/templates/instances/access_tab.html b/instances/templates/instances/access_tab.html
index d1680bd0..295ef621 100644
--- a/instances/templates/instances/access_tab.html
+++ b/instances/templates/instances/access_tab.html
@@ -88,7 +88,7 @@
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %}
-
{% trans "You need shut down your instance and enter a new root password." %}
+
{% trans "Shut the VM down, or run it with its QEMU guest agent, and enter a new root password." %}
- {% if instance.status == 5 %}
+ {% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %}
{% else %}
@@ -111,7 +111,7 @@
{% endif %}
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %}
-
{% trans "You need shut down your instance and choose your public key." %}
+
{% 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." %}
- {% if instance.status == 5 %}
+ {% if instance.status == 5 or instance.status == 1 and instance.guest_agent_ready %}
{% else %}
diff --git a/instances/tests/test_root_pass.py b/instances/tests/test_root_pass.py
index d8f75818..67442d42 100644
--- a/instances/tests/test_root_pass.py
+++ b/instances/tests/test_root_pass.py
@@ -2,11 +2,13 @@ import json
import socket
from unittest.mock import MagicMock, patch
+from django.contrib.messages import get_messages
from django.test import RequestFactory, TestCase
from django.urls import reverse
from computes.models import Compute
from instances.models import Instance
+from libvirt import libvirtError
class SetRootPassTestCase(TestCase):
@@ -115,3 +117,69 @@ class SetRootPassTestCase(TestCase):
self.assertEqual(response.status_code, 302)
mock_sock.settimeout.assert_called()
+
+
+class GuestAgentAccessTestCase(TestCase):
+ """A running VM whose guest agent answers gets its root password and SSH
+ key through the agent; gstfsd serves only a shut-off VM."""
+
+ def setUp(self):
+ from accounts.models import UserSSHKey
+ from django.contrib.auth import get_user_model
+
+ self.client.login(request=RequestFactory().get("/"), username="admin", password="admin")
+ compute = Compute.objects.create(name="agent-compute", hostname="192.0.2.1", type=1)
+ self.instance = Instance.objects.create(compute=compute, name="agent-vm",
+ uuid="11111111-2222-3333-4444-555555555555")
+ admin = get_user_model().objects.get(username="admin")
+ self.key = UserSSHKey.objects.create(user=admin, keyname="laptop", keypublic="ssh-ed25519 AAAA laptop")
+ patcher = patch.object(Instance, "proxy")
+ self.proxy = patcher.start()
+ self.addCleanup(patcher.stop)
+ self.proxy.get_status.return_value = 1 # running
+ self.proxy.is_agent_ready.return_value = True
+
+ def post(self, name, data):
+ with patch("instances.views.gstfsd_request") as gstfsd:
+ response = self.client.post(reverse(f"instances:{name}", args=[self.instance.id]), data)
+ gstfsd.assert_not_called()
+ return [str(m) for m in get_messages(response.wsgi_request)]
+
+ def test_the_agent_sets_the_root_password(self):
+ self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"}), ["Reset root password"])
+ user, crypted = self.proxy.set_user_password.call_args.args
+ self.assertEqual(user, "root")
+ self.assertTrue(crypted.startswith("$6$"))
+
+ def test_the_agent_adds_the_ssh_key(self):
+ self.assertEqual(self.post("add_public_key", {"sshkeyid": self.key.id}),
+ ["Installed new SSH public key laptop"])
+ self.proxy.add_authorized_key.assert_called_once_with("root", "ssh-ed25519 AAAA laptop")
+
+ def test_a_running_vm_without_its_agent_is_refused(self):
+ self.proxy.is_agent_ready.return_value = False
+ for name, data in (("rootpasswd", {"passwd": "S3cret-pass"}), ("add_public_key", {"sshkeyid": self.key.id})):
+ # the latest message: the redirect is not followed, so earlier ones stay
+ self.assertEqual(self.post(name, data)[-1], "Shut the VM down, or start its QEMU guest agent, and try again")
+ self.proxy.set_user_password.assert_not_called()
+ self.proxy.add_authorized_key.assert_not_called()
+
+ def test_an_agent_that_refuses_is_an_error_message(self):
+ self.proxy.set_user_password.side_effect = libvirtError("guest agent command failed: user root not found")
+ self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"}),
+ ["guest agent command failed: user root not found"])
+
+ def test_a_user_who_does_not_see_hosts_gets_no_connection_details(self):
+ from accounts.models import UserInstance
+ from django.contrib.auth import get_user_model
+ from libvirt import VIR_ERR_SYSTEM_ERROR, VIR_FROM_RPC
+
+ owner = get_user_model().objects.create_user("agent-owner", password="pw")
+ UserInstance.objects.create(user=owner, instance=self.instance, is_change=True)
+ self.client.force_login(owner)
+ err = libvirtError("Cannot recv data: 192.0.2.1:16509 Connection reset by peer")
+ err.get_error_domain = lambda: VIR_FROM_RPC
+ err.get_error_code = lambda: VIR_ERR_SYSTEM_ERROR
+ self.proxy.set_user_password.side_effect = err
+ self.assertEqual(self.post("rootpasswd", {"passwd": "S3cret-pass"}),
+ ["The host of this virtual machine cannot be reached right now."])
diff --git a/instances/tests/test_vm_audit_rows.py b/instances/tests/test_vm_audit_rows.py
index 6ed180eb..7c36e430 100644
--- a/instances/tests/test_vm_audit_rows.py
+++ b/instances/tests/test_vm_audit_rows.py
@@ -77,6 +77,15 @@ class VMRowTests(TestCase):
self.assertEqual(self.rows(), [("VM vm: reset root password: gstfsd error: timed out", False),
("VM vm: reset root password", True)])
+ def test_an_agent_error_is_a_failed_row_without_the_password(self):
+ self.proxy.get_status.return_value = 1
+ self.proxy.is_agent_ready.return_value = True
+ self.proxy.set_user_password.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds")
+ self.post("rootpasswd", {"passwd": "S3cret-pass"})
+ self.assertEqual(self.rows(), [
+ ("VM vm: reset root password: guest agent didn't respond to command within '10' seconds", False)])
+ self.assertFalse(Logs.objects.filter(message__contains="$6$").exists())
+
def test_console_password_rows_never_hold_the_password(self):
self.proxy.get_console_type.return_value = "vnc"
self.proxy.console_listens_locally.return_value = True
diff --git a/instances/views.py b/instances/views.py
index 0e988f54..2f8f9785 100755
--- a/instances/views.py
+++ b/instances/views.py
@@ -863,6 +863,11 @@ def gstfsd_request(hostname, data):
return reply
+# root password and SSH key: gstfsd changes a shut-off VM's disk, the QEMU
+# guest agent a running VM
+AGENT_OR_SHUT_OFF = _("Shut the VM down, or start its QEMU guest agent, and try again")
+
+
@require_POST
@serialize_instance_mutation
def set_root_pass(request, pk):
@@ -875,17 +880,20 @@ def set_root_pass(request, pk):
# gstfsd opens the domain by name: send the host's current one.
data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()}
- if instance.proxy.get_status() == 5:
+ status = instance.proxy.get_status()
+ if status == 5 or (status == 1 and instance.guest_agent_ready):
try:
with audit_vm(request, instance, "reset root password"):
- gstfsd_request(instance.compute.hostname, data)
- except util.OperationError as err:
- messages.error(request, err)
+ if status == 5:
+ gstfsd_request(instance.compute.hostname, data)
+ else:
+ instance.proxy.set_user_password("root", passwd_hash)
+ except (util.OperationError, libvirtError) as err:
+ messages.error(request, error_text(request, err))
else:
messages.success(request, _("Reset root password"))
else:
- msg = _("Please shutdown down your instance and then try again")
- messages.error(request, msg)
+ messages.error(request, AGENT_OR_SHUT_OFF)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#access"
)
@@ -910,19 +918,22 @@ def add_public_key(request, pk):
"vname": instance.proxy.instance.name(),
}
- if instance.proxy.get_status() == 5:
+ status = instance.proxy.get_status()
+ if status == 5 or (status == 1 and instance.guest_agent_ready):
try:
with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
- gstfsd_request(instance.compute.hostname, data)
- except util.OperationError as err:
- messages.error(request, err)
+ if status == 5:
+ gstfsd_request(instance.compute.hostname, data)
+ else:
+ instance.proxy.add_authorized_key("root", publickey.keypublic)
+ except (util.OperationError, libvirtError) as err:
+ messages.error(request, error_text(request, err))
else:
messages.success(request, _("Installed new SSH public key %(keyname)s") % {
"keyname": publickey.keyname
})
else:
- msg = _("Please shutdown down your instance and then try again")
- messages.error(request, msg)
+ messages.error(request, AGENT_OR_SHUT_OFF)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#access"
)
diff --git a/vrtManager/instance.py b/vrtManager/instance.py
index 54131b8f..bb064034 100644
--- a/vrtManager/instance.py
+++ b/vrtManager/instance.py
@@ -1,3 +1,4 @@
+import base64
import contextlib
import io
import ipaddress
@@ -73,6 +74,8 @@ PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
SNAPSHOT_NAME = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}")
# the longest window wvmInstance.usage() averages over
USAGE_MAX_WINDOW = 30
+# seconds the guest agent has to set a password or add a key
+AGENT_TIMEOUT = 10
# A running VM is cloned with a push backup that keeps the VM alive when the
# guest shuts down during the copy: libvirt 11.10.
LIVE_CLONE_MIN_LIBVIRT = 11_010_000
@@ -2522,6 +2525,24 @@ class wvmInstance(wvmConnect):
return util.get_xml_path(self._XMLDesc(0), func=_get_agent)
+ def _agent_command(self, command, arguments):
+ """One guest agent command that waits AGENT_TIMEOUT seconds at most:
+ libvirt's own calls for it wait for a hung agent indefinitely, and
+ the domain's agent timeout is shared by every agent call."""
+ request = json.dumps({"execute": command, "arguments": arguments})
+ qemuAgentCommand(self.instance, request, AGENT_TIMEOUT, 0)
+
+ def set_user_password(self, user, crypted):
+ """Set a user's password in the running guest through its agent;
+ crypted is a crypt(3) hash."""
+ password = base64.b64encode(crypted.encode()).decode()
+ self._agent_command("guest-set-user-password", {"username": user, "password": password, "crypted": True})
+
+ def add_authorized_key(self, user, key):
+ """Add an SSH public key to the user's authorized keys in the running
+ guest through its agent."""
+ self._agent_command("guest-ssh-add-authorized-keys", {"username": user, "keys": [key], "reset": False})
+
def is_agent_ready(self):
"""
Return connected state of an agent.
diff --git a/vrtManager/tests/test_agent_access.py b/vrtManager/tests/test_agent_access.py
new file mode 100644
index 00000000..5c37cf11
--- /dev/null
+++ b/vrtManager/tests/test_agent_access.py
@@ -0,0 +1,49 @@
+"""A running guest's root password and SSH keys through its QEMU guest
+agent: the password as a crypt(3) hash, the key added to those it has. Each
+is one agent command with a timeout: libvirt's own calls for them would wait
+for a hung agent indefinitely, and setting the domain's agent timeout would
+change it for every other agent call too."""
+
+import base64
+import json
+import unittest
+from unittest.mock import MagicMock, patch
+
+from django.conf import settings
+
+if not settings.configured:
+ settings.configure(MAC_OUI="52:54:10")
+
+from libvirt import libvirtError
+from vrtManager import instance as module
+from vrtManager.instance import wvmInstance
+
+
+class AgentAccessTestCase(unittest.TestCase):
+ def setUp(self):
+ self.vm = wvmInstance.__new__(wvmInstance) # no connection: only these calls
+ self.vm.instance = MagicMock()
+ patcher = patch.object(module, "qemuAgentCommand", return_value='{"return":{}}')
+ self.agent = patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def sent(self):
+ (dom, command, timeout, flags), = [c.args for c in self.agent.call_args_list]
+ self.assertIs(dom, self.vm.instance)
+ self.assertEqual((timeout, flags), (10, 0))
+ return json.loads(command)
+
+ def test_the_password_goes_as_a_hash(self):
+ self.vm.set_user_password("root", "$6$salt$hash")
+ self.assertEqual(self.sent(), {"execute": "guest-set-user-password", "arguments": {
+ "username": "root", "password": base64.b64encode(b"$6$salt$hash").decode(), "crypted": True}})
+
+ def test_the_key_is_added_to_the_users_keys(self):
+ self.vm.add_authorized_key("root", "ssh-ed25519 AAAA laptop")
+ self.assertEqual(self.sent(), {"execute": "guest-ssh-add-authorized-keys", "arguments": {
+ "username": "root", "keys": ["ssh-ed25519 AAAA laptop"], "reset": False}})
+
+ def test_the_agents_error_is_passed_on(self):
+ self.agent.side_effect = libvirtError("guest agent didn't respond to command within '10' seconds")
+ with self.assertRaisesRegex(libvirtError, "within '10' seconds"):
+ self.vm.set_user_password("root", "$6$salt$hash")