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

fix(gstfsd): only the panel, with the host's key, can change a VM's disk

gstfsd, the root service on each compute that rewrites a shut-off VM's
/etc/shadow and authorized_keys, did what any client on its port asked.
Now it needs GSTFSD_KEY (64 hexadecimal characters, in the unit's
/etc/default/webvirt-gstfsd) and does not start without it. Each
connection gets a new challenge; the panel signs its request for it
with HMAC-SHA256 and gstfsd checks the signature before any change, so
a request recorded on one connection is refused on another. gstfsd
signs its reply for the same challenge and the panel's nonce, and the
panel accepts only that reply.

Each compute has a gstfsd key, the host's GSTFSD_KEY: the compute forms
and the API take it and never show it, and an empty value on an edit
keeps it. The bootstrap script makes the key file once, readable by
root only; the README shows how to make it and where the panel takes
it. The traffic is still not encrypted: a remote panel's port stays
open to the panel's address only.
This commit is contained in:
catborise 2026-10-31 01:53:43 +03:00
parent a408cfdb2b
commit 2dab23cd9e
13 changed files with 388 additions and 108 deletions

View file

@ -1,4 +1,5 @@
from computes.models import Compute
from computes.validators import validate_gstfsd_key
from rest_framework import serializers
from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS
@ -11,6 +12,13 @@ class ComputeSerializer(serializers.ModelSerializer):
allow_blank=True,
allow_null=True,
)
gstfsd_key = serializers.CharField(
style={"input_type": "password"},
write_only=True,
required=False,
allow_blank=True,
validators=[validate_gstfsd_key],
)
# Use a radio input instead of a select input.
conn_types = (
(CONN_SSH, "SSH"),
@ -24,6 +32,9 @@ class ComputeSerializer(serializers.ModelSerializer):
# as on the web form, an empty password on an edit keeps the stored one
return value or (self.instance.password if self.instance else value)
def validate_gstfsd_key(self, value):
return value or (self.instance.gstfsd_key if self.instance else value)
class Meta:
model = Compute
fields = ["id", "name", "hostname", "login", "password", "type", "details", "migration_address"]
fields = ["id", "name", "hostname", "login", "password", "type", "details", "migration_address", "gstfsd_key"]

View file

@ -7,47 +7,55 @@ from computes.models import Compute
from .validators import validate_hostname
class KeepPasswordMixin:
"""The form never shows the stored password: left empty on an edit, it
stays as it is."""
class KeepSecretsMixin:
"""The form never shows the stored password or gstfsd key: left empty on
an edit, they stay as they are."""
def clean_password(self):
return self.cleaned_data["password"] or self.instance.password
def clean_gstfsd_key(self):
return self.cleaned_data["gstfsd_key"] or self.instance.gstfsd_key
class TcpComputeForm(KeepPasswordMixin, forms.ModelForm):
SECRET_WIDGETS = {"password": forms.PasswordInput(), "gstfsd_key": forms.PasswordInput()}
class TcpComputeForm(KeepSecretsMixin, forms.ModelForm):
hostname = forms.CharField(validators=[validate_hostname])
type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TCP)
class Meta:
model = Compute
widgets = {"password": forms.PasswordInput()}
widgets = SECRET_WIDGETS
fields = "__all__"
class SshComputeForm(forms.ModelForm):
class SshComputeForm(KeepSecretsMixin, forms.ModelForm):
hostname = forms.CharField(validators=[validate_hostname], label=_("FQDN/IP"))
type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SSH)
class Meta:
model = Compute
widgets = SECRET_WIDGETS
exclude = ["password"]
class TlsComputeForm(KeepPasswordMixin, forms.ModelForm):
class TlsComputeForm(KeepSecretsMixin, forms.ModelForm):
hostname = forms.CharField(validators=[validate_hostname])
type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_TLS)
class Meta:
model = Compute
widgets = {"password": forms.PasswordInput()}
widgets = SECRET_WIDGETS
fields = "__all__"
class SocketComputeForm(forms.ModelForm):
class SocketComputeForm(KeepSecretsMixin, forms.ModelForm):
hostname = forms.CharField(widget=forms.HiddenInput, initial="localhost")
type = forms.IntegerField(widget=forms.HiddenInput, initial=CONN_SOCKET)
class Meta:
model = Compute
fields = ["name", "details", "hostname", "type", "migration_address"]
widgets = SECRET_WIDGETS
fields = ["name", "details", "hostname", "type", "migration_address", "gstfsd_key"]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.18 on 2026-10-30 22:36
import computes.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('computes', '0006_login_password_length'),
]
operations = [
migrations.AddField(
model_name='compute',
name='gstfsd_key',
field=models.CharField(blank=True, default='', help_text="GSTFSD_KEY in the host's /etc/default/webvirt-gstfsd: the panel sets the root password and SSH key of a shut-off VM with it; empty: they cannot be set there", max_length=64, validators=[computes.validators.validate_gstfsd_key], verbose_name='gstfsd key'),
),
]

View file

@ -6,7 +6,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from libvirt import libvirtError, virConnect
from computes.validators import validate_hostname, validate_login, validate_migration_address
from computes.validators import validate_gstfsd_key, validate_hostname, validate_login, validate_migration_address
from vrtManager.connection import connection_manager
from vrtManager.hostdetails import wvmHostDetails
@ -29,6 +29,17 @@ class Compute(Model):
"empty: the host's own host name, which they must be able to resolve"
),
)
gstfsd_key = CharField(
_("gstfsd key"),
max_length=64,
blank=True,
default="",
validators=[validate_gstfsd_key],
help_text=_(
"GSTFSD_KEY in the host's /etc/default/webvirt-gstfsd: the panel sets the root password and SSH key "
"of a shut-off VM with it; empty: they cannot be set there"
),
)
@property
def migration_uri(self):

View file

@ -0,0 +1,49 @@
"""The gstfsd key of a compute, like its password: never shown, kept when
left empty on an edit, and only 64 hexadecimal characters."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from computes.models import Compute
KEY, OTHER = "5e" * 32, "a1" * 32
class GstfsdKeyTests(TestCase):
def setUp(self):
self.client.force_login(get_user_model().objects.create_superuser("gk-admin", "gk@example.com", "pw"))
def edit(self, compute, key):
data = {"name": compute.name, "hostname": compute.hostname, "login": "admin", "password": "",
"type": compute.type, "details": "", "gstfsd_key": key}
self.client.post(reverse("compute_update", args=[compute.id]), data)
compute.refresh_from_db()
return compute.gstfsd_key
def test_every_kind_of_compute_takes_it_and_keeps_it(self):
for conn_type, hostname in ((1, "192.0.2.10"), (2, "192.0.2.11"), (3, "192.0.2.12"), (4, "localhost")):
with self.subTest(type=conn_type):
compute = Compute.objects.create(name=f"gk{conn_type}", hostname=hostname, login="admin", type=conn_type)
self.assertEqual(self.edit(compute, KEY), KEY)
self.assertEqual(self.edit(compute, ""), KEY)
self.assertEqual(self.edit(compute, OTHER), OTHER)
page = self.client.get(reverse("compute_update", args=[compute.id])).content.decode()
self.assertIn('name="gstfsd_key"', page)
self.assertNotIn(OTHER, page)
def test_a_key_that_is_not_64_hexadecimal_characters_is_refused(self):
compute = Compute.objects.create(name="gk-bad", hostname="192.0.2.13", login="admin", type=1, gstfsd_key=KEY)
for bad in ("5E" * 32, "5e" * 31, "zz" * 32):
with self.subTest(key=bad):
self.assertEqual(self.edit(compute, bad), KEY)
def test_the_api_takes_it_but_never_shows_it(self):
compute = Compute.objects.create(name="gk-api", hostname="192.0.2.14", login="admin", type=1)
url = f"/api/v1/computes/{compute.id}/"
self.client.patch(url, {"gstfsd_key": KEY}, content_type="application/json")
self.client.patch(url, {"gstfsd_key": ""}, content_type="application/json")
self.assertEqual(self.client.patch(url, {"gstfsd_key": "x"}, content_type="application/json").status_code, 400)
compute.refresh_from_db()
self.assertEqual(compute.gstfsd_key, KEY)
self.assertNotIn("gstfsd_key", self.client.get(url).json())

View file

@ -51,3 +51,10 @@ def validate_migration_address(value):
if _hostname.fullmatch(value):
return
raise ValidationError(_("Enter an IP address or a host name, without a scheme or port"))
def validate_gstfsd_key(value):
"""The key gstfsd on the host signs with: GSTFSD_KEY in its
/etc/default/webvirt-gstfsd, 64 lowercase hexadecimal characters."""
if not re.fullmatch(r"[0-9a-f]{64}", value):
raise ValidationError(_("Enter the 64 hexadecimal characters of GSTFSD_KEY in the host's /etc/default/webvirt-gstfsd"))