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:
parent
a408cfdb2b
commit
2dab23cd9e
13 changed files with 388 additions and 108 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue