1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +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,3 +1,6 @@
import contextlib
import hashlib
import hmac
import json
import os
import socket
@ -7,8 +10,41 @@ import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
GSTFSD = Path(__file__).resolve().parent.parent.parent / "conf" / "daemon" / "gstfsd"
# the host's key (GSTFSD_KEY) and the panel's copy of it
KEY = "5e" * 32
def mac(kind, challenge, nonce, payload, key=KEY):
message = "\n".join(("gstfsd-v1", kind, challenge, nonce, payload)).encode()
return hmac.new(bytes.fromhex(key), message, hashlib.sha256).hexdigest()
def challenge_of(s):
"""The challenge gstfsd sends first, on one line."""
line = b""
while not line.endswith(b"\n"):
chunk = s.recv(1)
if not chunk:
raise ConnectionError("gstfsd closed the connection")
line += chunk
return json.loads(line)["challenge"]
def signed(challenge, data, key=KEY, nonce="0f" * 32):
"""The request frame of the panel for this challenge."""
request = json.dumps(data)
return json.dumps({"nonce": nonce, "request": request, "mac": mac("request", challenge, nonce, request, key)}).encode()
def reply_of(s, challenge=None, nonce="0f" * 32):
"""gstfsd's reply; with the challenge, checked to be signed for it."""
frame = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
if challenge is not None:
assert hmac.compare_digest(frame["mac"], mac("reply", challenge, nonce, frame["reply"])), frame
return json.loads(frame["reply"])
# A stand-in for libguestfs: the guest's operating systems ("roots", each with
# its mountpoints), its files, and what is mounted live in a JSON file.
@ -116,7 +152,7 @@ class GstfsdTestCase(unittest.TestCase):
os.environ,
PYTHONPATH=self.tmp.name,
FAKE_GUESTFS_STATE=str(self.state),
**extra,
**{"GSTFSD_KEY": KEY, **extra},
)
def _start(self, host, port, family=socket.AF_INET, **env):
@ -144,9 +180,10 @@ class GstfsdTestCase(unittest.TestCase):
with socket.socket(family, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect((host, port))
s.sendall(json.dumps(data).encode())
challenge = challenge_of(s)
s.sendall(signed(challenge, data))
# everything until gstfsd closes: more than one reply fails to parse
return json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
return reply_of(s, challenge)
def test_refuses_non_loopback_bind_without_opt_in(self):
result = subprocess.run(
@ -202,6 +239,59 @@ class GstfsdTestCase(unittest.TestCase):
self._start("127.0.0.1", port)
return port
def test_only_a_request_signed_for_this_connection_is_done(self):
port = self._serve()
data = {"action": "password", "vname": "vm", "passwd": "$6$intruder"}
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(("127.0.0.1", port))
recorded = signed(challenge_of(s), data) # a request of another connection, never sent there
def tampered(challenge):
frame = json.loads(signed(challenge, {"action": "password", "vname": "vm", "passwd": "$6$ok"}))
frame["request"] = json.dumps(data)
return json.dumps(frame).encode()
for name, frame in (
("unsigned", lambda challenge: json.dumps(data).encode()),
("another key", lambda challenge: signed(challenge, data, key="a1" * 32)),
("changed after signing", tampered),
("recorded on another connection", lambda challenge: recorded),
("a nonce that is no nonce", lambda challenge: signed(challenge, data, nonce="x")),
):
with self.subTest(name), socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(("127.0.0.1", port))
s.sendall(frame(challenge_of(s)))
reply = reply_of(s)
self.assertEqual(reply, {"return": "error", "message": "the request is not signed with this host's gstfsd key"})
state = json.loads(self.state.read_text())
self.assertEqual((state["writes"], state["history"]), (0, [])) # no appliance was started
def test_it_does_not_start_without_a_key(self):
for key in ("", "5E" * 32, "5e" * 31):
with self.subTest(key=key):
result = subprocess.run([sys.executable, str(GSTFSD)], env=self._env(GSTFSD_KEY=key),
capture_output=True, text=True, timeout=20)
self.assertNotEqual(result.returncode, 0)
self.assertIn("GSTFSD_KEY", result.stderr)
def test_the_panel_asks_with_the_host_s_key(self):
from unittest.mock import patch
from instances import views
from vrtManager.util import OperationError
port = self._serve()
compute = SimpleNamespace(name="host-a", hostname="127.0.0.1", gstfsd_key=KEY)
with patch.object(views, "GSTFSD_PORT", port):
self.assertEqual(views.gstfsd_request(compute, {"action": "password", "vname": "vm", "passwd": "$6$panel"}),
{"return": "success"})
compute.gstfsd_key = "a1" * 32 # the key of another host
with self.assertRaisesRegex(OperationError, "not signed with this host's gstfsd key"):
views.gstfsd_request(compute, {"action": "password", "vname": "vm", "passwd": "$6$other"})
self.assertIn("root:$6$panel:", json.loads(self.state.read_text())["files"]["/etc/shadow"])
def test_root_password_is_set_on_the_guest_root(self):
reply = self._request("127.0.0.1", self._serve(), {"action": "password", "vname": "vm", "passwd": "$6$new"})
self.assertEqual(reply, {"return": "success"})
@ -278,14 +368,15 @@ class GstfsdTestCase(unittest.TestCase):
def test_a_request_in_two_pieces(self):
port = self._serve()
body = json.dumps({"action": "password", "vname": "vm", "passwd": "$6$parts"}).encode()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(("127.0.0.1", port))
challenge = challenge_of(s)
body = signed(challenge, {"action": "password", "vname": "vm", "passwd": "$6$parts"})
s.sendall(body[:10])
time.sleep(0.3)
s.sendall(body[10:])
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
reply = reply_of(s, challenge)
self.assertEqual(reply, {"return": "success"})
def test_a_request_over_the_limit_is_refused(self):
@ -293,8 +384,8 @@ class GstfsdTestCase(unittest.TestCase):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(("127.0.0.1", port))
s.sendall(b'{"action": "publickey", "vname": "vm", "key": "' + b"A" * 70000 + b'"}')
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
s.sendall(signed(challenge_of(s), {"action": "publickey", "vname": "vm", "key": "A" * 70000}))
reply = reply_of(s)
self.assertEqual(reply["return"], "error")
self.assertEqual(json.loads(self.state.read_text())["writes"], 0)
@ -303,6 +394,7 @@ class GstfsdTestCase(unittest.TestCase):
self._start("127.0.0.1", port, GSTFSD_DEADLINE="2")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", port))
challenge_of(s)
s.settimeout(0.25)
start = time.monotonic()
reply = b""
@ -316,7 +408,7 @@ class GstfsdTestCase(unittest.TestCase):
except OSError:
break
self.assertTrue(reply, "gstfsd kept reading a request that never completes")
self.assertEqual(json.loads(reply)["return"], "error")
self.assertEqual(json.loads(json.loads(reply)["reply"])["return"], "error")
def test_a_slow_write_is_stopped_at_the_deadline(self):
# the whole change is bounded, not only its start: a write still
@ -391,7 +483,8 @@ class GstfsdTestCase(unittest.TestCase):
proc = self._start("127.0.0.1", port, GSTFSD_DEADLINE="1")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", port))
s.sendall(b'{"action": ')
challenge_of(s)
s.sendall(b'{"nonce": ')
time.sleep(1.5) # gstfsd times the request out and answers a closed socket
reply = self._request("127.0.0.1", port, {"action": "password", "vname": "vm", "passwd": "$6$after"})
self.assertEqual(reply, {"return": "success"})
@ -424,15 +517,16 @@ class GstfsdTestCase(unittest.TestCase):
port = free_port()
self._start("127.0.0.1", port, GSTFSD_DEADLINE="2")
self._update_state(launch_seconds=3)
request = json.dumps({"action": "password", "vname": "vm", "passwd": "$6$slow"}).encode()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(("127.0.0.1", port))
start = time.monotonic()
challenge = challenge_of(s)
request = signed(challenge, {"action": "password", "vname": "vm", "passwd": "$6$slow"})
s.sendall(request[:10])
time.sleep(1.5)
s.sendall(request[10:])
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
reply = reply_of(s, challenge)
self.assertEqual(reply["return"], "error")
self.assertLess(time.monotonic() - start, 2.8)
@ -450,7 +544,7 @@ class GstfsdTestCase(unittest.TestCase):
self._update_state(launch_seconds=2)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", port))
s.sendall(json.dumps({"action": "password", "vname": "vm", "passwd": "$6$gone"}).encode())
s.sendall(signed(challenge_of(s), {"action": "password", "vname": "vm", "passwd": "$6$gone"}))
time.sleep(0.5)
time.sleep(3) # longer than the change would have taken
self.assertEqual(json.loads(self.state.read_text())["writes"], 0)
@ -471,9 +565,10 @@ class GstfsdTestCase(unittest.TestCase):
class GstfsdClientTestCase(unittest.TestCase):
"""The panel side sends the whole request and reads the whole reply."""
"""The panel side checks that the reply is gstfsd's, signed for its request."""
def test_a_reply_in_pieces_is_read_whole(self):
def ask(self, *sent):
"""gstfsd_request against a server that sends these, in pieces."""
import threading
from unittest.mock import patch
@ -483,20 +578,45 @@ class GstfsdClientTestCase(unittest.TestCase):
server.bind(("127.0.0.1", 0))
server.listen(1)
self.addCleanup(server.close)
received = []
def serve():
conn, _ = server.accept()
with conn:
received.append(conn.recv(65536))
conn.sendall(b'{"return": ')
time.sleep(0.2)
conn.sendall(b'"success"}')
with conn, contextlib.suppress(OSError): # the panel may hang up first
conn.sendall(sent[0])
conn.recv(65536)
for piece in sent[1:]:
time.sleep(0.1)
conn.sendall(piece)
thread = threading.Thread(target=serve)
thread.start()
self.addCleanup(thread.join, 5)
compute = SimpleNamespace(name="host-a", hostname="127.0.0.1", gstfsd_key=KEY)
with patch.object(views, "GSTFSD_PORT", server.getsockname()[1]):
reply = views.gstfsd_request("127.0.0.1", {"action": "publickey", "vname": "vm", "key": "k"})
thread.join(5)
self.assertEqual(reply, {"return": "success"})
self.assertEqual(json.loads(received[0])["vname"], "vm")
return views.gstfsd_request(compute, {"action": "publickey", "vname": "vm", "key": "k"})
def test_a_reply_that_is_not_signed_is_refused(self):
from vrtManager.util import OperationError
challenge = json.dumps({"challenge": "1a" * 32}).encode() + b"\n"
# something else on the port, which claims success in pieces, or sends what is no reply
for name, sent in (
("unsigned success", (challenge, b'{"reply": "{\\"return\\": ', b'\\"success\\"}", "mac": ""}')),
("a mac that is no hex", (challenge, b'{"reply": "{}", "mac": "\xc3\xa9"}')),
("a mac that is no text", (challenge, b'{"reply": "{}", "mac": "\\ud800"}')),
("a challenge line without end", (b"{" + b" " * 70000,)),
("a reply too long", (challenge, b'{"reply": "' + b"x" * 70000 + b'"}')),
):
with self.subTest(name), self.assertRaises(OperationError):
self.ask(*sent)
def test_a_host_without_a_key_is_not_asked(self):
from unittest.mock import patch
from instances import views
from vrtManager.util import OperationError
with patch("instances.views.socket.create_connection") as connect:
with self.assertRaisesRegex(OperationError, "Set the gstfsd key of host-a first"):
views.gstfsd_request(SimpleNamespace(name="host-a", hostname="127.0.0.1", gstfsd_key=""), {})
connect.assert_not_called()

View file

@ -1,6 +1,4 @@
import json
import socket
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from django.contrib.messages import get_messages
from django.test import RequestFactory, TestCase
@ -9,6 +7,7 @@ from django.urls import reverse
from computes.models import Compute
from instances.models import Instance
from libvirt import libvirtError
from vrtManager.util import OperationError
class SetRootPassTestCase(TestCase):
@ -25,17 +24,12 @@ class SetRootPassTestCase(TestCase):
uuid="87654321-4321-4321-4321-210987654321",
)
@patch("socket.socket")
@patch("instances.views.gstfsd_request")
@patch.object(Instance, "proxy")
def test_set_root_pass_post(self, mock_proxy, mock_socket_cls):
def test_set_root_pass_post(self, mock_proxy, gstfsd):
# Renamed on the host: gstfsd must get the name of this UUID's domain
mock_proxy.instance.name.return_value = "renamed-on-host"
mock_proxy.get_status.return_value = 5 # shut off
mock_sock = MagicMock()
mock_socket_cls.return_value = mock_sock
replies = iter([json.dumps({"return": "success"}).encode(), b""] * 2)
# one reply, then gstfsd closes the connection
mock_sock.recv.side_effect = lambda size: next(replies)
response = self.client.post(
reverse("instances:rootpasswd", args=[self.instance.id]),
@ -44,10 +38,9 @@ class SetRootPassTestCase(TestCase):
# Redirects back to referer or instance detail
self.assertEqual(response.status_code, 302)
# Check socket sent the command with hashed password
self.assertTrue(mock_sock.sendall.called)
sent_bytes = mock_sock.sendall.call_args[0][0]
data = json.loads(sent_bytes.decode())
# gstfsd of the VM's host gets the hashed password
compute, data = gstfsd.call_args.args
self.assertEqual(compute, self.compute)
self.assertEqual(data["action"], "password")
self.assertEqual(data["vname"], "renamed-on-host")
# SHA-512 crypt with a random salt, not the old fixed one
@ -62,44 +55,23 @@ class SetRootPassTestCase(TestCase):
response = self.client.post(reverse("instances:rootpasswd", args=[99999]), {"passwd": "secret"})
self.assertEqual(response.status_code, 404)
@patch("socket.socket")
@patch("instances.views.gstfsd_request")
@patch.object(Instance, "proxy")
def test_same_password_gets_a_different_salt_each_time(self, mock_proxy, mock_socket_cls):
def test_same_password_gets_a_different_salt_each_time(self, mock_proxy, gstfsd):
mock_proxy.instance.name.return_value = "root-pass-vm"
mock_proxy.get_status.return_value = 5
mock_sock = MagicMock()
mock_socket_cls.return_value = mock_sock
replies = iter([json.dumps({"return": "success"}).encode(), b""] * 2)
# one reply, then gstfsd closes the connection
mock_sock.recv.side_effect = lambda size: next(replies)
url = reverse("instances:rootpasswd", args=[self.instance.id])
self.client.post(url, {"passwd": "samePassword1"})
self.client.post(url, {"passwd": "samePassword1"})
hashes = [json.loads(c[0][0].decode())["passwd"] for c in mock_sock.sendall.call_args_list]
hashes = [c.args[1]["passwd"] for c in gstfsd.call_args_list]
self.assertEqual(len(hashes), 2)
self.assertNotEqual(hashes[0], hashes[1])
@patch("socket.socket")
@patch("instances.views.gstfsd_request", side_effect=OperationError("gstfsd error: timed out"))
@patch.object(Instance, "proxy")
def test_unreachable_gstfsd_is_an_error_message_not_a_crash(self, mock_proxy, mock_socket_cls):
mock_proxy.instance.name.return_value = "root-pass-vm"
mock_proxy.get_status.return_value = 5
mock_sock = MagicMock()
mock_socket_cls.return_value = mock_sock
mock_sock.connect.side_effect = socket.timeout("timed out")
response = self.client.post(
reverse("instances:rootpasswd", args=[self.instance.id]), {"passwd": "x"}
)
self.assertEqual(response.status_code, 302)
mock_sock.settimeout.assert_called()
@patch("socket.socket")
@patch.object(Instance, "proxy")
def test_add_public_key_unreachable_gstfsd_is_an_error_message(self, mock_proxy, mock_socket_cls):
def test_unreachable_gstfsd_is_an_error_message_not_a_crash(self, mock_proxy, gstfsd):
from accounts.models import UserSSHKey
from django.contrib.auth import get_user_model
@ -107,16 +79,11 @@ class SetRootPassTestCase(TestCase):
key = UserSSHKey.objects.create(user=admin, keyname="k", keypublic="ssh-ed25519 AAAA k")
mock_proxy.instance.name.return_value = "root-pass-vm"
mock_proxy.get_status.return_value = 5
mock_sock = MagicMock()
mock_socket_cls.return_value = mock_sock
mock_sock.connect.side_effect = ConnectionRefusedError()
response = self.client.post(
reverse("instances:add_public_key", args=[self.instance.id]), {"sshkeyid": key.id}
)
self.assertEqual(response.status_code, 302)
mock_sock.settimeout.assert_called()
for name, data in (("rootpasswd", {"passwd": "x"}), ("add_public_key", {"sshkeyid": key.id})):
with self.subTest(name):
response = self.client.post(reverse(f"instances:{name}", args=[self.instance.id]), data)
self.assertEqual(response.status_code, 302)
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)][-1], "gstfsd error: timed out")
class GuestAgentAccessTestCase(TestCase):

View file

@ -4,9 +4,12 @@ except ImportError:
import crypt
import difflib
import functools
import hashlib
import hmac
import json
import os
import re
import secrets
import socket
import subprocess
import time
@ -955,24 +958,51 @@ def migrate_plan(request, pk):
GSTFSD_PORT = 16510
GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GSTFSD_DEADLINE)
GSTFSD_MAX_FRAME = 64 * 1024 # a challenge or a reply of gstfsd is far shorter
def gstfsd_request(hostname, data):
"""Send one request to gstfsd and return its JSON reply; an error reply or
a failed exchange raises OperationError with the reason."""
def gstfsd_mac(key, kind, challenge, nonce, payload):
"""The HMAC-SHA256 gstfsd signs and checks with (see conf/daemon/gstfsd)."""
message = "\n".join(("gstfsd-v1", kind, challenge, nonce, payload)).encode()
return hmac.new(bytes.fromhex(key), message, hashlib.sha256).hexdigest()
def gstfsd_request(compute, data):
"""Send one request to the compute's gstfsd, signed with its key, and
return the reply gstfsd signed for it; an error reply or a failed or
unsigned exchange raises OperationError with the reason."""
if not compute.gstfsd_key:
raise util.OperationError(_("Set the gstfsd key of %(compute)s first (Computes, edit the host)") % {
"compute": compute.name})
try:
# the host name may resolve to IPv4 or IPv6
s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
s = socket.create_connection((compute.hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
except OSError as err:
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
try:
s.sendall(json.dumps(data).encode())
stream = s.makefile("rb")
challenge = json.loads(stream.readline(GSTFSD_MAX_FRAME))["challenge"]
nonce, request = secrets.token_hex(32), json.dumps(data)
s.sendall(json.dumps({"nonce": nonce, "request": request,
"mac": gstfsd_mac(compute.gstfsd_key, "request", challenge, nonce, request)}).encode())
# gstfsd sends one reply and closes the connection
reply = json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
except (OSError, ValueError) as err:
answer = stream.read(GSTFSD_MAX_FRAME + 1)
if len(answer) > GSTFSD_MAX_FRAME:
raise ValueError("the reply is too long")
frame = json.loads(answer)
reply = json.loads(frame["reply"])
except (OSError, ValueError, KeyError, TypeError) as err:
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
finally:
s.close()
given = frame.get("mac")
if not (isinstance(given, str) and re.fullmatch(r"[0-9a-f]{64}", given) and hmac.compare_digest(
given, gstfsd_mac(compute.gstfsd_key, "reply", challenge, nonce, frame["reply"]))):
# gstfsd refused the request (a wrong key): its error says so; any
# other reply that is not signed is no answer of gstfsd
refused = isinstance(reply, dict) and reply.get("return") == "error"
raise util.OperationError(_("gstfsd error: %(err)s") % {
"err": reply.get("message") if refused else _("the reply is not signed with the key")})
if reply["return"] != "success":
raise util.OperationError(reply["message"])
return reply
@ -1014,7 +1044,7 @@ def set_root_pass(request, pk):
try:
with audit_vm(request, instance, "reset root password"):
if status == 5:
gstfsd_request(instance.compute.hostname, data)
gstfsd_request(instance.compute, data)
else:
instance.proxy.set_user_password("root", passwd_hash)
except (util.OperationError, libvirtError) as err:
@ -1054,7 +1084,7 @@ def add_public_key(request, pk):
try:
with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
if status == 5:
gstfsd_request(instance.compute.hostname, data)
gstfsd_request(instance.compute, data)
else:
instance.proxy.add_authorized_key("root", publickey.keypublic)
except (util.OperationError, libvirtError) as err: