diff --git a/pyanaconda/core/constants.py b/pyanaconda/core/constants.py index 5232e6d87d9..0f333273b76 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -128,6 +128,7 @@ THREAD_DBUS_TASK = "AnaTaskThread" THREAD_SUBSCRIPTION = "AnaSubscriptionThread" THREAD_SUBSCRIPTION_SPOKE_INIT = "AnaSubscriptionSpokeInitThread" +THREAD_RDP_OBTAIN_HOSTNAME = "AnaRDPObtainHostnameThread" # Geolocation constants diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index 283e55a33a3..6d745b6ba39 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -238,8 +238,9 @@ def _run_program(argv, root='/', stdin=None, stdout=None, env_prune=None, return (proc.returncode, output_string) -def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', env_prune=None, - log_output=True, binary_output=False, do_preexec=True): +def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', + env_prune=None, env_add=None, log_output=True, binary_output=False, + do_preexec=True): """ Run an external program and redirect the output to a file. :param command: The command to run @@ -248,6 +249,7 @@ def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', env_prune :param stdout: Optional file object to redirect stdout and stderr to. :param root: The directory to chroot to before running command. :param env_prune: environment variable to remove before execution + :param env_add: environment variables added for the execution :param log_output: whether to log the output of command :param binary_output: whether to treat the output of command as binary data :param do_preexec: whether to use a preexec_fn for subprocess.Popen @@ -255,7 +257,8 @@ def execWithRedirect(command, argv, stdin=None, stdout=None, root='/', env_prune """ argv = [command] + argv return _run_program(argv, stdin=stdin, stdout=stdout, root=root, env_prune=env_prune, - log_output=log_output, binary_output=binary_output, do_preexec=do_preexec)[0] + env_add=env_add, log_output=log_output, binary_output=binary_output, + do_preexec=do_preexec)[0] def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, env_add=None, @@ -266,6 +269,8 @@ def execWithCapture(command, argv, stdin=None, root='/', env_prune=None, env_add :param argv: The argument list :param stdin: The file object to read stdin from. :param root: The directory to chroot to before running command. + :param env_prune: environment variable to remove before execution + :param env_add: environment variables added for the execution :param log_output: Whether to log the output of command :param filter_stderr: Whether stderr should be excluded from the returned output :param do_preexec: whether to use the preexec function diff --git a/pyanaconda/gnome_remote_desktop.py b/pyanaconda/gnome_remote_desktop.py index 8cff8351397..affd6861f77 100644 --- a/pyanaconda/gnome_remote_desktop.py +++ b/pyanaconda/gnome_remote_desktop.py @@ -25,7 +25,9 @@ from systemd import journal from pyanaconda import network from pyanaconda.core import util -from pyanaconda.core.util import execWithCapture, startProgram +from pyanaconda.core.constants import THREAD_RDP_OBTAIN_HOSTNAME +from pyanaconda.core.threads import thread_manager +from pyanaconda.core.util import execWithRedirect, startProgram from pyanaconda.core.i18n import _ @@ -72,12 +74,11 @@ def shutdown_server(): class GRDServer(object): - def __init__(self, anaconda, root="/", ip=None, name=None, + def __init__(self, anaconda, root="/", ip=None, rdp_username="", rdp_password=""): self.root = root self.ip = ip self.rdp_username = rdp_username - self.name = name self.rdp_password = rdp_password self.anaconda = anaconda self.log = get_stdout_logger() @@ -87,17 +88,22 @@ def __init__(self, anaconda, root="/", ip=None, name=None, # start by checking we have openssl available if not os.path.exists(OPENSSL_BINARY_PATH): - stdoutLog.critical("No openssl binary found, can't generate certificates " - "for GNOME remote desktop. Aborting.") - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) + self._fail_with_error("No openssl binary found, can't generate certificates " + "for GNOME remote desktop. Aborting.") # start by checking we have GNOME remote desktop available if not os.path.exists(GRD_BINARY_PATH): # we assume there that the main binary being present implies grdctl is there as well - stdoutLog.critical("GNOME remote desktop tooling is not available. Aborting.") - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) + self._fail_with_error("GNOME remote desktop tooling is not available. Aborting.") + + def _fail_with_error(self, *args): + """Kill Anaconda with with message for user. + + Send ipmi error message. + """ + stdoutLog.critical(*args) + util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) + sys.exit(1) def _handle_rdp_certificates(self): """Generate SSL certificate and use it for incoming RDP connection.""" @@ -105,14 +111,18 @@ def _handle_rdp_certificates(self): # then create folder for the certs os.makedirs(GRD_RDP_CERT_DIR) # generate the certs - execWithCapture(OPENSSL_BINARY_PATH, - ["req", "-new", - "-newkey", "rsa:4096", - "-days", "720", "-nodes", "-x509", - "-subj", "/C=DE/ST=NONE/L=NONE/O=GNOME/CN=localhost", - "-out", GRD_RDP_CERT, - "-keyout", GRD_RDP_CERT_KEY] - ) + ret = execWithRedirect(OPENSSL_BINARY_PATH, + ["req", "-new", + "-newkey", "rsa:4096", + "-days", "720", "-nodes", "-x509", + "-subj", "/C=DE/ST=NONE/L=NONE/O=GNOME/CN=localhost", + "-out", GRD_RDP_CERT, + "-keyout", GRD_RDP_CERT_KEY] + ) + if ret != 0: + self._fail_with_error( + "Can't generate certificates for Gnome remote desktop. Aborting." + ) # tell GNOME remote desktop to use these certificates self._run_grdctl(["rdp", "set-tls-cert", GRD_RDP_CERT]) self._run_grdctl(["rdp", "set-tls-key", GRD_RDP_CERT_KEY]) @@ -140,21 +150,38 @@ def _find_network_address(self): if not self.ip: return - # FIXME: resolve this somehow, - # so it does not get stuck for 2 minutes in some VMs + def _get_hostname(self): + """Start thread to obtain hostname from DNS server asynchronously. + + This can take a while so do not wait for the result just print it when available. + """ + thread_manager.add_thread(name=THREAD_RDP_OBTAIN_HOSTNAME, + target=self._get_hostname_in_thread, + args=[self.ip, self.log] + ) - if self.ip.find(':') != -1: - ipstr = "[%s]" % (self.ip,) - else: - ipstr = self.ip + @staticmethod + def _get_hostname_in_thread(ip, stdout_log): + """Obtain hostname from the DNS query. + This call will be done from the thread to avoid situations where DNS is too slow or + doesn't exists and we are waiting for the reply about 2 minutes. + + :raises: ValueError and socket.herror + """ try: - hinfo = socket.gethostbyaddr(self.ip) + hinfo = socket.gethostbyaddr(ip) if len(hinfo) == 3: # Consider as coming from a valid DNS record only if single IP is returned if len(hinfo[2]) == 1: - self.name = hinfo[0] + name = hinfo[0] + stdout_log.info(_("GNOME remote desktop RDP host name: %s"), name) + except socket.herror as e: + if ip.find(':') != -1: + ipstr = "[%s]" % (ip,) + else: + ipstr = ip log.debug("Exception caught trying to get host name of %s: %s", ipstr, e) def _run_grdctl(self, argv): @@ -168,7 +195,8 @@ def _run_grdctl(self, argv): # extend the base argv by the caller provided arguments combined_argv = base_argv + argv # make sure HOME is set to /root or else settings might not be saved - execWithCapture("grdctl", combined_argv, env_add={"HOME": "/root"}) + if execWithRedirect("grdctl", combined_argv, env_add={"HOME": "/root"}) != 0: + self._fail_with_error("Gnome remote desktop invocation failed!") def _start_grd_process(self): """Start the GNOME remote desktop process.""" @@ -184,16 +212,12 @@ def _start_grd_process(self): env_add={"HOME": "/root"}) self.log.info("GNOME remote desktop is now running.") except OSError: - stdoutLog.critical("Could not start GNOME remote desktop. Aborting.") - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) + self._fail_with_error("Could not start GNOME remote desktop. Aborting.") def start_grd_rdp(self): # check if RDP user name & password are set if not self.rdp_password or not self.rdp_username: - stdoutLog.critical("RDP user name or password not set. Aborting.") - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) + self._fail_with_error("RDP user name or password not set. Aborting.") self.log.info(_("Starting GNOME remote desktop in RDP mode...")) @@ -208,12 +232,14 @@ def start_grd_rdp(self): network.wait_for_connectivity() try: self._find_network_address() - self.log.info(_("GNOME remote desktop RDP IP: %s"), self.ip) - self.log.info(_("GNOME remote desktop RDP host name: %s"), self.name) except (socket.herror, ValueError) as e: - stdoutLog.critical("GNOME remote desktop RDP: Could not find network address: %s", e) - util.ipmi_abort(scripts=self.anaconda.ksdata.scripts) - sys.exit(1) + self._fail_with_error("GNOME remote desktop RDP: Could not find network address: %s", + e) # Lets start GRD. self._start_grd_process() + + # Print connection information to user + self.log.info(_("GNOME remote desktop RDP IP: %s"), self.ip) + # Print hostname when available (run in separate thread to avoid blocking) + self._get_hostname() diff --git a/tests/unit_tests/pyanaconda_tests/modules/storage/test_module_storage.py b/tests/unit_tests/pyanaconda_tests/modules/storage/test_module_storage.py index 03ac9128b47..ef7bc8111d8 100644 --- a/tests/unit_tests/pyanaconda_tests/modules/storage/test_module_storage.py +++ b/tests/unit_tests/pyanaconda_tests/modules/storage/test_module_storage.py @@ -1525,7 +1525,7 @@ def test_mount_filesystems(self, core_run_program, blivet_mount, makedirs, chmod # remounted the root filesystem core_run_program.assert_any_call( ['mount', '--rbind', '/mnt/sysimage', '/mnt/sysroot'], - stdin=None, stdout=None, root='/', env_prune=None, + stdin=None, stdout=None, root='/', env_prune=None, env_add=None, log_output=True, binary_output=False, do_preexec=True) @patch_dbus_get_proxy diff --git a/tests/unit_tests/pyanaconda_tests/ui/test_rdp.py b/tests/unit_tests/pyanaconda_tests/ui/test_rdp.py new file mode 100644 index 00000000000..f2b46195cbb --- /dev/null +++ b/tests/unit_tests/pyanaconda_tests/ui/test_rdp.py @@ -0,0 +1,317 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. You should have received a copy of the +# GNU General Public License along with this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# +import unittest +import socket +import pytest +from systemd import journal + +from unittest.mock import patch, Mock, call +from contextlib import contextmanager + +from pyanaconda import gnome_remote_desktop + + +class RDPShutdownTestCase(unittest.TestCase): + """Simple test case for starting RDP server.""" + + def test_shutdown_server(self): + """Test shutdown_server method.""" + # Do nothing when grd_process is None + gnome_remote_desktop.grd_process = None + gnome_remote_desktop.shutdown_server() + assert gnome_remote_desktop.grd_process is None + + # Gracefully kill GRD process + gnome_remote_desktop.grd_process = Mock() + gnome_remote_desktop.shutdown_server() + gnome_remote_desktop.grd_process.kill.assert_called_once_with() + + # Error during the GRD process kill + gnome_remote_desktop.grd_process = Mock() + gnome_remote_desktop.grd_process.kill.side_effect = SystemError + gnome_remote_desktop.shutdown_server() + + +class RDPServerTestCase(unittest.TestCase): + + def setUp(self): + self.grd_server = None + self.mock_anaconda = Mock() + + patcher_os = patch("pyanaconda.gnome_remote_desktop.os") + patcher_util = patch("pyanaconda.gnome_remote_desktop.util") + patcher_execWithRedirect = patch("pyanaconda.gnome_remote_desktop.execWithRedirect") + + self.mock_os = patcher_os.start() + self.mock_util = patcher_util.start() + self.mock_execWithRedirect = patcher_execWithRedirect.start() + + def _create_grd_server(self): + self.mock_os.path.exists.side_effect = [True, True] + self.grd_server = gnome_remote_desktop.GRDServer(self.mock_anaconda) + self.mock_os.mock_reset() + + def _reset_mocks(self): + self.mock_anaconda.reset_mock() + self.mock_util.reset_mock() + self.mock_os.reset_mock() + self.mock_execWithRedirect.reset_mock() + + @contextmanager + def _check_for_failure(self): + with pytest.raises(SystemExit): + yield + self.mock_util.ipmi_abort.assert_called_once() + + def test_grd_init(self): + """Test creation of the GRDServer.""" + # Fail on missing openssl + self.mock_os.path.exists.return_value = False + with self._check_for_failure(): + gnome_remote_desktop.GRDServer(self.mock_anaconda) + self.mock_os.path.exists.assert_called_once_with("/usr/bin/openssl") + + # Fail on missing GRD + self._reset_mocks() + self.mock_os.path.exists.side_effect = [True, False] + with self._check_for_failure(): + gnome_remote_desktop.GRDServer(self.mock_anaconda) + self.mock_os.path.exists.assert_has_calls([ + call("/usr/bin/openssl"), + call("/usr/libexec/gnome-remote-desktop-daemon")]) + + # Success on GRD creation + self._reset_mocks() + self.mock_os.path.exists.side_effect = [True, True] + gnome_remote_desktop.GRDServer(self.mock_anaconda) + self.mock_os.path.exists.assert_has_calls([ + call("/usr/bin/openssl"), + call("/usr/libexec/gnome-remote-desktop-daemon")]) + self.mock_util.ipmi_abort.assert_not_called() + + def test_start_grp_rpd_missing_username_password(self): + """Test running of GRD server with missing pass and username.""" + self._create_grd_server() + + # Missing username and password + with self._check_for_failure(): + self.grd_server.start_grd_rdp() + + # Missing username + self._reset_mocks() + with self._check_for_failure(): + self.grd_server.rdp_password = "secret" + self.grd_server.start_grd_rdp() + + def test_run_grctl(self): + """Test GRD server grdctl call method abstraction.""" + self._create_grd_server() + + # failed call + self.mock_execWithRedirect.return_value = 1 + with self._check_for_failure(): + self.grd_server._run_grdctl(["rdp", "failed-call"]) + self.mock_execWithRedirect.assert_called_once_with( + "grdctl", + [ + "--headless", + "rdp", + "failed-call" + ], + env_add={"HOME": "/root"} + ) + + # success call + self._reset_mocks() + self.mock_execWithRedirect.return_value = 0 + self.grd_server._run_grdctl(["rdp", "success-call"]) + self.mock_execWithRedirect.assert_called_once_with( + "grdctl", + [ + "--headless", + "rdp", + "success-call" + ], + env_add={"HOME": "/root"} + ) + + def test_grd_certificate_generation(self): + """Test certificate generation for RDP.""" + self._create_grd_server() + self.grd_server._run_grdctl = Mock() + + # check certificate creation failure + self.mock_execWithRedirect.return_value = 1 + with self._check_for_failure(): + self.grd_server._handle_rdp_certificates() + self.mock_os.makedirs.assert_called_once_with("/root/.local/share/gnome-remote-desktop/") + self.mock_execWithRedirect.assert_called_once_with( + "/usr/bin/openssl", + ["req", "-new", + "-newkey", "rsa:4096", + "-days", "720", "-nodes", "-x509", + "-subj", "/C=DE/ST=NONE/L=NONE/O=GNOME/CN=localhost", + "-out", "/root/.local/share/gnome-remote-desktop/rdp.crt", + "-keyout", "/root/.local/share/gnome-remote-desktop/rdp.key"] + ) + self.grd_server._run_grdctl.assert_not_called() + + # check certificate creation success + self._reset_mocks() + self.mock_execWithRedirect.return_value = 0 + self.grd_server._handle_rdp_certificates() + self.mock_os.makedirs.assert_called_once_with("/root/.local/share/gnome-remote-desktop/") + self.mock_execWithRedirect.assert_called_once_with( + "/usr/bin/openssl", + ["req", "-new", + "-newkey", "rsa:4096", + "-days", "720", "-nodes", "-x509", + "-subj", "/C=DE/ST=NONE/L=NONE/O=GNOME/CN=localhost", + "-out", "/root/.local/share/gnome-remote-desktop/rdp.crt", + "-keyout", "/root/.local/share/gnome-remote-desktop/rdp.key"] + ) + self.grd_server._run_grdctl.assert_has_calls([ + call(["rdp", "set-tls-cert", "/root/.local/share/gnome-remote-desktop/rdp.crt"]), + call(["rdp", "set-tls-key", "/root/.local/share/gnome-remote-desktop/rdp.key"])]) + + @patch("pyanaconda.gnome_remote_desktop.time") + @patch("pyanaconda.gnome_remote_desktop.network") + def test_run_find_network_address(self, mock_network, mock_time): + """Test GRD server is able to obtain IP address.""" + self._create_grd_server() + + # failed to get ip + mock_network.get_first_ip_address.return_value = None + self.grd_server._find_network_address() + assert self.grd_server.ip is None + mock_time.sleep.assert_has_calls(list(call(1) for _ in range(5))) + + # success to get ip + mock_network.reset_mock() + mock_time.sleep.reset_mock() + mock_network.get_first_ip_address.return_value = "192.168.0.22" + self.grd_server._find_network_address() + assert self.grd_server.ip == "192.168.0.22" + mock_time.sleep.assert_not_called() + + @patch("pyanaconda.gnome_remote_desktop.socket") + @patch("pyanaconda.gnome_remote_desktop.log") + def test_grd_rdp_hostname_retrieval(self, mock_log, mock_socket): + """Test GRD code for hostname retrieval.""" + self._create_grd_server() + mock_stdout_log = Mock() + + # check error raise + mock_socket.gethostbyaddr.side_effect = socket.herror + mock_socket.herror = socket.herror + self.grd_server._get_hostname_in_thread("192.168.0.1", mock_stdout_log) + mock_log.debug.assert_called_once() + mock_stdout_log.info.assert_not_called() + + # check error raise with IPv6 + mock_log.debug.reset_mock() + mock_stdout_log.info.reset_mock() + self.grd_server._get_hostname_in_thread("[cafe::cafe]", mock_stdout_log) + mock_log.debug.assert_called_once() + mock_stdout_log.info.assert_not_called() + + # check failure returned tuple is broken + mock_stdout_log.info.reset_mock() + mock_socket.gethostbyaddr.side_effect = None + mock_socket.gethostbyaddr.return_value = ["only one value"] + self.grd_server._get_hostname_in_thread("192.168.0.1", mock_stdout_log) + mock_stdout_log.assert_not_called() + + # check failure returned tuple contains multiple IPs + mock_socket.gethostbyaddr.side_effect = None + mock_socket.gethostbyaddr.return_value = ["super-best-hostname.xyz", + None, + ["1.1.1.1", "2.2.2.2"]] + self.grd_server._get_hostname_in_thread("192.168.0.1", mock_stdout_log) + mock_stdout_log.assert_not_called() + + # check success + mock_log.debug.reset_mock() + mock_socket.gethostbyaddr.side_effect = None + mock_socket.gethostbyaddr.return_value = ["super-best-hostname.xyz", None, ["1.1.1.1"]] + self.grd_server._get_hostname_in_thread("192.168.0.1", mock_stdout_log) + mock_log.debug.assert_not_called() + mock_stdout_log.info.call_args.args[0].endswith("super-best-hostname.xyz") + + @patch("pyanaconda.gnome_remote_desktop.startProgram") + @patch("pyanaconda.gnome_remote_desktop.journal") + @patch("pyanaconda.gnome_remote_desktop.thread_manager") + @patch("pyanaconda.gnome_remote_desktop.network") + def test_run_grp_rdp_start_server(self, mock_network, mock_thread_manager, mock_journal, + mock_startProgram): + """Test GRD server start of RDP.""" + self._create_grd_server() + self.grd_server.rdp_username = "goofy" + self.grd_server.rdp_password = "topsecret" + # patch _run_grdctl method to make the testing easier (tested separately) + self.grd_server._run_grdctl = Mock() + self.grd_server._find_network_address = Mock() + self.grd_server._handle_rdp_certificates = Mock() + + # failed to obtain IP + self.grd_server._find_network_address.side_effect = ValueError + with self._check_for_failure(): + self.grd_server.start_grd_rdp() + mock_network.wait_for_connectivity.assert_called_once() + + # failed to start grd server + self._reset_mocks() + self.grd_server._find_network_address.side_effect = None + mock_startProgram.side_effect = OSError + with self._check_for_failure(): + self.grd_server.start_grd_rdp() + + # successful execution + self._reset_mocks() + mock_stdout_stream = Mock() + mock_stderr_stream = Mock() + mock_journal.reset_mock() + mock_journal.stream.side_effect = [mock_stdout_stream, mock_stderr_stream] + mock_journal.LOG_INFO = journal.LOG_INFO + mock_journal.LOG_ERR = journal.LOG_ERR + mock_network.reset_mock() + mock_startProgram.reset_mock() + mock_startProgram.side_effect = None + mock_grd_process = Mock() + mock_startProgram.return_value = mock_grd_process + mock_thread_manager.reset_mock() + self.grd_server._handle_rdp_certificates.reset_mock() + self.grd_server._find_network_address.reset_mock() + + self.grd_server.start_grd_rdp() + + self.grd_server._handle_rdp_certificates.assert_called_once() + mock_network.wait_for_connectivity.assert_called_once() + self.grd_server._find_network_address.assert_called_once() + + mock_journal.stream.assert_has_calls([ + call("gnome-remote-desktop", priority=journal.LOG_INFO), + call("gnome-remote-desktop", priority=journal.LOG_ERR) + ]) + mock_startProgram.assert_called_once_with( + ["/usr/libexec/gnome-remote-desktop-daemon", "--headless"], + stdout=mock_stdout_stream, + stderr=mock_stderr_stream, + env_add={"HOME": "/root"} + ) + assert gnome_remote_desktop.grd_process is mock_grd_process