1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 04:36:48 +00:00

feat(instances): a VM's TPM 2.0 is switched on and off on its Options tab

A VM made without a TPM, a Windows 10 to be upgraded to Windows 11, had
no way to get one but the XML editor. The Options tab has a TPM 2.0
switch, as the RNG one: it adds an emulated TPM of the host's model to
the definition for the next start, or removes it. Its state stays on the
host until the VM is undefined, so a TPM added again has it back. A TPM
the switch did not make (passthrough, 1.2, encrypted, with its own state
path) is left to the XML editor, and a host without swtpm says so.
This commit is contained in:
catborise 2026-11-01 16:38:14 +03:00
parent 2a05576e18
commit e35342bff1
10 changed files with 288 additions and 130 deletions

View file

@ -289,6 +289,10 @@ class Instance(models.Model):
def config_rng(self) -> str | None:
return self.proxy.get_rng()
@cached_property
def config_tpm(self) -> str | None:
return self.proxy.get_tpm()
@cached_property
def config_on_crash(self) -> str:
return self.proxy.get_on_crash()

View file

@ -936,6 +936,26 @@
</div>
</div>
</form>
<form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_tpm' instance.id %}" method="post">{% csrf_token %}
<div class="row g-3 align-items-center">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold mb-0" for="tpm">{% trans "TPM" %}</label>
<p class="small text-body-secondary mb-0" id="tpm-help">{% trans "An emulated TPM 2.0, as Windows 11 needs with UEFI Secure Boot. Its state stays on the host until the VM is destroyed: a TPM added again has it back. From the next start." %}</p>
</div>
<div class="col-12 col-lg-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="tpm" value="True" id="tpm" aria-describedby="tpm-help tpm-state"
{% if instance.config_tpm %}checked{% endif %}{% if instance.config_tpm == "custom" or not instance.config_tpm and not tpm_support %} disabled{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="tpm">{% trans "TPM 2.0" %}</label>
</div>
{% if instance.config_tpm == "custom" %}
<p class="small text-body-secondary mt-1 mb-0" id="tpm-state">{% trans "The VM has its own TPM: change it in the XML editor." %}</p>
{% elif not instance.config_tpm and not tpm_support %}
<p class="small text-body-secondary mt-1 mb-0" id="tpm-state">{% trans "This host offers no TPM 2.0: install swtpm on it." %}</p>
{% endif %}
</div>
</div>
</form>
<form class="list-group-item p-3 p-md-4" action="{% url 'instances:set_on_crash' instance.id %}" method="post">{% csrf_token %}
<div class="row g-3 align-items-center">
<div class="col-12 col-lg-4">

View file

@ -130,6 +130,11 @@ EDITS = {
lambda t: not t.xpath("devices/rng"),
["devices/rng"], True, False,
),
"set_tpm": (
lambda vm: vm.set_tpm(True),
lambda t: first(t, "devices/tpm/backend/@version") == "2.0",
["devices/tpm"], True, False,
),
"set_on_crash": (
lambda vm: vm.set_on_crash("restart"),
lambda t: t.findtext("on_crash") == "restart",

View file

@ -29,9 +29,9 @@ class PendingEditsTestCase(TestCase):
compute = Compute.objects.create(name="pe", hostname="127.0.0.1:1", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=compute, name="pe-vm", uuid="aaaaaaaa-1111-2222-3333-444444444444")
def page(self, live=(), config=(), nics=((), ()), status=1, **getters):
def page(self, live=(), config=(), nics=((), ()), status=1, tpm_model="tpm-crb", **getters):
"""live and config: disks; nics and getters (get_vcpu=(2, 4)): values of
the live and the persistent definition."""
the live and the persistent definition; tpm_model: the host's TPM."""
with patch("instances.models.wvmInstance") as wvm, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
):
@ -54,6 +54,7 @@ class PendingEditsTestCase(TestCase):
proxy.get_console_type.return_value = "vnc"
proxy.get_video_models.return_value = ["vga", "virtio"]
proxy.get_cache_modes.return_value = {"default": "Default", "none": "Disabled"}
proxy.get_tpm_model.return_value = tpm_model
response = self.client.get(reverse("instances:instance", args=[self.instance.id]))
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
@ -220,6 +221,20 @@ class PendingEditsTestCase(TestCase):
self.assertEqual(doc.xpath("//input[@name='rng']/@checked"), ["checked"])
self.assertEqual(doc.xpath("//select[@name='on_crash']/option[@selected]/@value"), ["restart"])
def test_the_tpm_switch_is_off_where_it_cannot_change(self):
tpm = "//input[@name='tpm']"
doc = self.page(get_tpm=("emulator", "emulator"))
self.assertEqual((doc.xpath(f"{tpm}/@checked"), doc.xpath(f"{tpm}/@disabled")), (["checked"], []))
doc = self.page(get_tpm=("custom", "custom"))
self.assertTrue(doc.xpath(f"{tpm}/@disabled"))
self.assertIn("change it in the XML editor", doc.xpath("string(//p[@id='tpm-state'])"))
# no swtpm: none can be added, one the VM has can still go
doc = self.page(get_tpm=(None, None), tpm_model=None)
self.assertTrue(doc.xpath(f"{tpm}/@disabled"))
self.assertIn("install swtpm", doc.xpath("string(//p[@id='tpm-state'])"))
doc = self.page(get_tpm=("emulator", "emulator"), tpm_model=None)
self.assertEqual(doc.xpath(f"{tpm}/@disabled"), [])
def test_the_users_tab_shows_each_owner_and_offers_only_new_owners(self):
from accounts.models import UserInstance

View file

@ -56,6 +56,7 @@ urlpatterns = [
path("<int:pk>/set_guest_agent/", views.set_guest_agent, name="set_guest_agent"),
path("<int:pk>/set_video_model/", views.set_video_model, name="set_video_model"),
path("<int:pk>/set_rng/", views.set_rng, name="set_rng"),
path("<int:pk>/set_tpm/", views.set_tpm, name="set_tpm"),
path("<int:pk>/set_on_crash/", views.set_on_crash, name="set_on_crash"),
path("<int:pk>/change_network/", views.change_network, name="change_network"),
path("<int:pk>/add_network/", views.add_network, name="add_network"),

View file

@ -308,6 +308,7 @@ def _instance_page(request, pk, instance, compute):
memory_host = instance.proxy.get_max_memory()
bus_host = instance.proxy.get_disk_bus_types(instance.arch, instance.machine)
cdrom_buses = cdrom_bus_choices(instance)
tpm_support = bool(instance.proxy.get_tpm_model(instance.arch, instance.machine))
networks_host = sorted(instance.proxy.get_networks())
interfaces_host = sorted(instance.proxy.get_ifaces())
nwfilters_host = instance.proxy.get_nwfilters()
@ -1883,6 +1884,20 @@ def set_rng(request, pk):
return _back(request, pk, "options")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_tpm(request, pk):
instance = get_instance(request.user, pk)
enabled = bool(request.POST.get("tpm"))
try:
with audit_vm(request, instance, "add TPM" if enabled else "remove TPM"):
instance.proxy.set_tpm(enabled)
except util.OperationError as err:
messages.error(request, err)
return _back(request, pk, "options")
@require_POST
@superuser_only
@serialize_instance_mutation