From 8b8caa85e0d410e169ff3f018d8647c858926231 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Tue, 8 Sep 2026 12:49:48 +0300 Subject: [PATCH 1/6] Replace Windows WinRM with SSH. --- coriolis/osmorphing/osmount/windows.py | 13 +- coriolis/osmorphing/windows.py | 12 +- coriolis/providers/provider_utils.py | 55 +- .../tests/osmorphing/osmount/test_windows.py | 16 +- coriolis/tests/osmorphing/test_windows.py | 10 +- .../tests/providers/test_provider_utils.py | 66 +- coriolis/tests/test_windows_ssh.py | 588 ++++++++++++++++ coriolis/tests/test_wsman.py | 238 +------ coriolis/windows_ssh.py | 634 ++++++++++++++++++ coriolis/wsman.py | 249 +------ requirements.txt | 1 - 11 files changed, 1316 insertions(+), 566 deletions(-) create mode 100644 coriolis/tests/test_windows_ssh.py create mode 100644 coriolis/windows_ssh.py diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 9dfea037d..0a8b1a6e0 100644 --- a/coriolis/osmorphing/osmount/windows.py +++ b/coriolis/osmorphing/osmount/windows.py @@ -6,7 +6,7 @@ from oslo_log import log as logging -from coriolis import constants, exception, utils, wsman +from coriolis import constants, exception, utils, windows_ssh from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -21,16 +21,13 @@ def __init__(self, *args, **kwargs): self._unlocked_volumes: list[str] = [] def _connect(self): - connection_info = self._connection_info - - host = connection_info["ip"] - port = connection_info.get("port", 5986) + host = self._connection_info["ip"] self._event_manager.progress_update( - "Connecting to WinRM host: %(host)s:%(port)s" % {"host": host, "port": port} + "Connecting through SSH to OSMorphing host on: %s" % host ) - self._conn = wsman.WSManConnection.from_connection_info( - connection_info, self._osmount_operation_timeout + self._conn = windows_ssh.WindowsSSHConnection.from_connection_info( + self._connection_info, self._osmount_operation_timeout ) def get_connection(self): diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index 1bafd766b..443a77106 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -272,12 +272,14 @@ def _add_dism_driver(self, driver_path): LOG.info("Adding driver: %s" % driver_path) dism_path = self._get_dism_path() try: + # Pass a raw /driver: path. Nested quotes become part of the + # path after SSH wraps the command in cmd.exe /c. return self._conn.exec_command( dism_path, [ "/add-driver", "/image:%s" % self._os_root_dir, - "/driver:\"%s\"" % driver_path, + "/driver:%s" % driver_path, "/recurse", "/forceunsigned", ], @@ -304,7 +306,7 @@ def _add_dism_driver(self, driver_path): def _mount_disk_image(self, path): LOG.info("Mounting disk image: %s" % path) drive_letter, stderr = self._conn.exec_ps_command( - "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % path, + '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % path, include_stderr=True, ) if not drive_letter: @@ -341,6 +343,7 @@ def _expand_archive(self, path, destination, overwrite=True): self._conn.exec_ps_command("rm -recurse -force %s" % destination) self._conn.exec_ps_command( + "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": path, "destination": destination}, @@ -1024,8 +1027,9 @@ def _setup_qemu_agent_installation_local_script( ) else: self._conn.exec_ps_command( - "Copy-Item '%s' -Destination '%s'" - % (msi_source_path, msi_dest_path) + "Copy-Item -Force '%s' -Destination '%s'" + % (msi_source_path, msi_dest_path), + ignore_stdout=True, ) local_script = QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { "agent_msi_path": "%s\\%s" diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index e13be2723..f05637c00 100644 --- a/coriolis/providers/provider_utils.py +++ b/coriolis/providers/provider_utils.py @@ -6,7 +6,7 @@ import requests from oslo_log import log as logging -from coriolis import constants, exception, utils, wsman +from coriolis import constants, exception, utils LOG = logging.getLogger(__name__) @@ -170,6 +170,10 @@ def _poll_instance_until_reachable_ssh( timeout: int = 600, poll_interval: int = 10, ): + if not connection_info.get("password") and not connection_info.get("pkey"): + raise exception.InvalidInput( + "SSH connection info must include password or pkey." + ) start = time.time() while (time.time() - start) < timeout: try: @@ -177,8 +181,8 @@ def _poll_instance_until_reachable_ssh( hostname=connection_info["ip"], port=connection_info["port"], username=connection_info["username"], - password=connection_info["password"], - pkey=connection_info["pkey"], + password=connection_info.get("password"), + pkey=connection_info.get("pkey"), ) try: # "exit 0" should work across platforms. @@ -201,30 +205,6 @@ def _poll_instance_until_reachable_ssh( ) -def _poll_instance_until_reachable_winrm( - connection_info: dict, - timeout: int = 600, - poll_interval: int = 10, -): - start = time.time() - while (time.time() - start) < timeout: - try: - conn = wsman.WSManConnection.from_connection_info(connection_info) - conn.exec_ps_command("whoami") - return - except Exception as ex: - LOG.debug( - f"Could not conect to Windows host: {str(ex)}. " - f"Retrying, time left: {timeout - (time.time() - start)}." - ) - time.sleep(poll_interval) - - raise exception.CoriolisException( - f"Operation timed out after waiting {timeout}s for Windows host to " - f"be accessible via WinRM." - ) - - def poll_instance_until_reachable( connection_info: dict, protocol: str = constants.PROTOCOL_SSH, @@ -239,22 +219,19 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol, "ssh" or "winrm" + :param protocol: connection protocol. Only "ssh" is supported. :param timeout: the maximum amount of time to wait :param poll_interval: the amount of time to wait between retries """ - # TODO(lpetrut): consider including the connection protocol in the - # connection info. We'd have to modify a few schemas used during os - # morphing. We currently pick the protocol based on the OS type but - # we may want to use SSH on Windows as well. - if protocol == constants.PROTOCOL_SSH: - helper = _poll_instance_until_reachable_ssh - elif protocol == constants.PROTOCOL_WINRM: - helper = _poll_instance_until_reachable_winrm - else: + if protocol != constants.PROTOCOL_SSH: raise exception.InvalidInput( f"Unsupported instance connection protocol: {protocol}" ) - return helper( - connection_info=connection_info, timeout=timeout, poll_interval=poll_interval + ssh_connection_info = dict(connection_info) + if ssh_connection_info.get("port") is None: + ssh_connection_info["port"] = 22 + return _poll_instance_until_reachable_ssh( + connection_info=ssh_connection_info, + timeout=timeout, + poll_interval=poll_interval, ) diff --git a/coriolis/tests/osmorphing/osmount/test_windows.py b/coriolis/tests/osmorphing/osmount/test_windows.py index 3d2ffe222..71fc00c72 100644 --- a/coriolis/tests/osmorphing/osmount/test_windows.py +++ b/coriolis/tests/osmorphing/osmount/test_windows.py @@ -24,8 +24,8 @@ class CoriolisTestException(Exception): class WindowsMountToolsTestCase(test_base.CoriolisBaseTestCase): """Test suite for the WindowsMountTools class.""" - @mock.patch.object(windows.wsman, 'WSManConnection') - def setUp(self, mock_wsman_connection): + @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() self.ssh = mock.MagicMock() @@ -43,15 +43,21 @@ def setUp(self, mock_wsman_connection): mock.sentinel.ignore_devices, mock.sentinel.operation_timeout, ) - self.tools._conn = mock_wsman_connection + self.tools._conn = mock.MagicMock() - @mock.patch.object(windows.wsman.WSManConnection, 'from_connection_info') + @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) + expected_conn_info = { + "ip": "127.0.0.1", + "username": "random_username", + "password": "random_password", + "pkey": "random_pkey", + } mock_from_connection_info.assert_called_once_with( - self.conn_info, mock.sentinel.operation_timeout + expected_conn_info, mock.sentinel.operation_timeout ) def test_get_connection(self): diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index 220eff560..ce6c7fa58 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -168,7 +168,7 @@ def test__add_dism_driver(self, mock_get_worker_os_drive_path): [ '/add-driver', '/image:%s' % self.os_root_dir, - '/driver:"%s"' % mock.sentinel.driver_path, + '/driver:%s' % mock.sentinel.driver_path, '/recurse', '/forceunsigned', ], @@ -209,7 +209,7 @@ def test__mount_disk_image(self): result = self.morphing_tools._mount_disk_image(mock.sentinel.path) self.conn.exec_ps_command.assert_called_once_with( - "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" + '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % mock.sentinel.path, include_stderr=True, ) @@ -251,6 +251,7 @@ def test__expand_archive_remove_destination(self): [ mock.call("rm -recurse -force %s" % destination), mock.call( + "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": mock.sentinel.archive_path, "destination": destination}, @@ -1441,8 +1442,9 @@ def test_setup_qemu_agent_installation_local_script_from_path( exp_msi_dest_path = "C:\\Cloudbase-Init\\qemu-ga.msi" self.morphing_tools._conn.download_file.assert_not_called() self.morphing_tools._conn.exec_ps_command.assert_called_once_with( - "Copy-Item '%s' -Destination '%s'" - % (fake_msi_source_path, exp_msi_dest_path) + "Copy-Item -Force '%s' -Destination '%s'" + % (fake_msi_source_path, exp_msi_dest_path), + ignore_stdout=True, ) exp_script = windows.QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index 7884019ba..de6a6dd4f 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -351,52 +351,42 @@ def test_poll_instance_ssh_timeout( poll_interval=poll_interval, ) - @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) - @mock.patch("time.sleep") - def test_poll_instance_winrm( - self, - mock_sleep, - mock_wsman, - ): - mock_conn = mock.Mock() - mock_conn.exec_ps_command.side_effect = [Exception, mock.sentinel.stdout] - mock_wsman.from_connection_info.return_value = mock_conn - poll_interval = 5 + def test_poll_instance_missing_auth(self): + connection_info = { + "ip": "1.2.3.4", + "port": 22, + "username": "Administrator", + } + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, + connection_info=connection_info, + protocol=constants.PROTOCOL_SSH, + timeout=600, + poll_interval=5, + ) - connection_info = self._get_mock_conn_info() - provider_utils.poll_instance_until_reachable( + def test_poll_instance_winrm_rejected(self): + connection_info = { + "ip": "1.2.3.4", + "port": 5986, + "username": "Administrator", + "password": "pwned", + } + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, connection_info=connection_info, protocol=constants.PROTOCOL_WINRM, timeout=30, - poll_interval=poll_interval, - ) - - mock_wsman.from_connection_info.assert_has_calls( - [mock.call(connection_info)] * 2, any_order=True + poll_interval=5, ) - mock_conn.exec_ps_command.assert_has_calls([mock.call("whoami")] * 2) - mock_sleep.assert_called_once_with(poll_interval) - @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) - @mock.patch("time.sleep") - @mock.patch("time.time") - def test_poll_instance_winrm_timeout( - self, - mock_time, - mock_sleep, - mock_wsman, - ): - poll_interval = 5 - mock_time.side_effect = [x * 10 for x in range(20)] - mock_conn = mock.Mock() - mock_conn.exec_ps_command.side_effect = IOError - mock_wsman.from_connection_info.return_value = mock_conn + def test_poll_instance_unsupported_protocol(self): connection_info = self._get_mock_conn_info() self.assertRaises( - exception.CoriolisException, + exception.InvalidInput, provider_utils.poll_instance_until_reachable, connection_info=connection_info, - protocol=constants.PROTOCOL_WINRM, - timeout=30, - poll_interval=poll_interval, + protocol="ftp", ) diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py new file mode 100644 index 000000000..3b66f0d31 --- /dev/null +++ b/coriolis/tests/test_windows_ssh.py @@ -0,0 +1,588 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +import logging +import socket +from unittest import mock + +import paramiko + +from coriolis import exception, windows_ssh +from coriolis.tests import test_base + + +def _fake_exec_channel(stdout=b"std_out", stderr=b"std_err", exit_code=0): + """SSH channel that yields stdout/stderr once, then reports exit.""" + out = [stdout] + err = [stderr] + channel = mock.Mock() + + def recv_ready(): + return bool(out[0]) + + def recv_stderr_ready(): + return bool(err[0]) + + def recv(_size): + data = out[0] + out[0] = b"" + return data + + def recv_stderr(_size): + data = err[0] + err[0] = b"" + return data + + channel.recv_ready.side_effect = recv_ready + channel.recv_stderr_ready.side_effect = recv_stderr_ready + channel.recv.side_effect = recv + channel.recv_stderr.side_effect = recv_stderr + channel.exit_status_ready.return_value = True + channel.recv_exit_status.return_value = exit_code + return channel + + +class WindowsSSHConnectionTestCase(test_base.CoriolisBaseTestCase): + """Test suite for WindowsSSHConnection.""" + + def setUp(self): + super(WindowsSSHConnectionTestCase, self).setUp() + self.conn = windows_ssh.WindowsSSHConnection() + self.conn._ssh = mock.Mock() + self.cmd = "test_cmd" + self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] + self.url = "http://example.com/file" + self.remote_path = "/remote/path" + + def test__init__timeout(self): + connection = windows_ssh.WindowsSSHConnection() + self.assertEqual(connection._conn_timeout, windows_ssh.DEFAULT_TIMEOUT) + + def test__init__timeout_set(self): + connection = windows_ssh.WindowsSSHConnection(timeout=100) + self.assertEqual(connection._conn_timeout, 100) + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_uses_ssh( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_ssh = mock.Mock() + mock_connect_ssh.return_value = mock_ssh + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + "port": 22, + } + + result = windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 22) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=22, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + self.assertIsInstance(result, windows_ssh.WindowsSSHConnection) + self.assertIs(result._ssh, mock_ssh) + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_omitted_port_defaults_to_ssh( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_connect_ssh.return_value = mock.Mock() + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + } + + windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 22) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=22, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + + @mock.patch.object(windows_ssh.WindowsSSHConnection, "_start_ps_session") + @mock.patch.object(windows_ssh.utils, "connect_ssh") + @mock.patch.object(windows_ssh.utils, "wait_for_port_connectivity") + def test_from_connection_info_keeps_custom_ssh_port( + self, mock_wait_for_port, mock_connect_ssh, mock_start_ps + ): + mock_connect_ssh.return_value = mock.Mock() + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + "port": 2222, + } + + windows_ssh.WindowsSSHConnection.from_connection_info(connection_info) + + mock_wait_for_port.assert_called_once_with("127.0.0.1", 2222) + mock_connect_ssh.assert_called_once_with( + hostname="127.0.0.1", + port=2222, + username="user", + password="pass", + pkey=None, + ) + mock_start_ps.assert_called_once_with() + + def test_from_connection_info_missing_keys(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + {"username": "user", "password": "pass"}, + ) + + def test_from_connection_info_invalid_type(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + "invalid-connection-type", + ) + + def test_from_connection_info_missing_auth(self): + self.assertRaises( + ValueError, + windows_ssh.WindowsSSHConnection.from_connection_info, + {"ip": "127.0.0.1", "username": "user"}, + ) + + def test_disconnect(self): + ssh = self.conn._ssh + self.conn._close_ps_session = mock.Mock() + self.conn.disconnect() + self.conn._close_ps_session.assert_called_once_with() + ssh.close.assert_called_once_with() + self.assertIsNone(self.conn._ssh) + + def test_set_timeout(self): + self.conn.set_timeout(50) + self.assertEqual(self.conn._conn_timeout, 50) + + @mock.patch.object(windows_ssh.utils, "connect_ssh") + def test_connect_invalid_credentials(self, mock_connect_ssh): + auth_error = paramiko.AuthenticationException("bad auth") + connect_error = exception.CoriolisException( + "Failed to setup SSH client: bad auth" + ) + connect_error.__cause__ = auth_error + mock_connect_ssh.side_effect = connect_error + conn = windows_ssh.WindowsSSHConnection() + self.assertRaises( + exception.NotAuthorized, + conn.connect, + host="127.0.0.1", + port=22, + username="user", + password="pass", + ) + + def test__exec_command(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) + + self.assertEqual(std_out, "std_out") + self.assertEqual(std_err, "std_err") + self.assertEqual(exit_code, 0) + self.conn._ssh.exec_command.assert_called_once_with( + "cmd.exe /c \"test_cmd -RecoveryPassword 'ShouldNotBeLogged'\"", + timeout=float(self.conn._conn_timeout), + ) + + def test__exec_command_reads_stderr_progress_without_stdout(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel(stdout=b"", stderr=b"progress" * 100) + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + std_out, std_err, exit_code = self.conn._exec_command("dism.exe", []) + + self.assertEqual(std_out, "") + self.assertIn("progress", std_err) + self.assertEqual(exit_code, 0) + + def test__exec_command_timeout(self): + self.conn._ssh.exec_command.side_effect = socket.timeout + self.assertRaises( + exception.OSMorphingSSHOperationTimeout, + self.conn._exec_command, + self.cmd, + self.args, + ) + + def test_exec_command(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + + sanitized_cmd = "cmd.exe /c \"test_cmd -RecoveryPassword '***'\"" + exp_log = "DEBUG:coriolis.windows_ssh:Executing Windows SSH command: %s" % ( + sanitized_cmd + ) + with self.assertLogs("coriolis.windows_ssh", level=logging.DEBUG) as log_cm: + std_out = self.conn.exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertIn(exp_log, log_cm.output) + + def test_exec_command_exception(self): + stdout = mock.Mock() + stdout.channel = _fake_exec_channel(exit_code=1) + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.assertRaises( + exception.CoriolisException, self.conn.exec_command, self.cmd, self.args + ) + + def test_is_reg_exe(self): + self.assertTrue(windows_ssh._is_reg_exe("reg.exe")) + self.assertTrue(windows_ssh._is_reg_exe("C:\\Windows\\System32\\reg.exe")) + self.assertFalse(windows_ssh._is_reg_exe("dism.exe")) + + def test_exec_command_releases_ps_handles_for_reg(self): + self.conn._release_ps_registry_handles = mock.Mock() + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) + self.conn._release_ps_registry_handles.assert_called_once_with() + + def test_exec_command_skips_handle_release_for_other_cmds(self): + self.conn._release_ps_registry_handles = mock.Mock() + stdout = mock.Mock() + stdout.channel = _fake_exec_channel() + self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) + self.conn._release_ps_registry_handles.assert_not_called() + + def test_release_ps_registry_handles_closes_session(self): + self.conn._close_ps_session = mock.Mock() + self.conn._release_ps_registry_handles() + self.conn._close_ps_session.assert_called_once_with(wait=True) + + def test_escape_trailing_backslash_for_ssh(self): + odd = "dism.exe /get-drivers /image:F:\\" + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh(odd), + "dism.exe /get-drivers /image:F:\\\\", + ) + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), + "reg.exe unload HKLM\\x", + ) + even = "cmd /c dir C:\\\\" + self.assertEqual( + windows_ssh._escape_trailing_backslash_for_ssh(even), + even, + ) + + def test_exec_ps_command(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out\r\n", "", 0) + result = self.conn.exec_ps_command(self.cmd, include_stderr=False) + self.conn._invoke_persistent_ps.assert_called_once_with(self.cmd, timeout=None) + self.assertEqual(result, "std_out") + + def test_exec_ps_command_with_stderr(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out\n", "stderr", 0) + result = self.conn.exec_ps_command(self.cmd, include_stderr=True) + self.assertEqual(result, ("std_out", "stderr")) + + def test_exec_ps_command_nonzero_exit(self): + self.conn._invoke_persistent_ps = mock.Mock() + self.conn._invoke_persistent_ps.return_value = ("std_out", "stderr", 1) + self.assertRaises( + exception.CoriolisException, self.conn.exec_ps_command, self.cmd + ) + + def test_test_path(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.return_value = "True" + result = self.conn.test_path("test_path") + self.conn.exec_ps_command.assert_called_once_with('Test-Path -Path "test_path"') + self.assertTrue(result) + + def test_download_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.download_file(self.url, self.remote_path) + self.conn.exec_ps_command.assert_called_once() + + def test_download_file_exception(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.side_effect = exception.CoriolisException + self.assertRaises( + exception.CoriolisException, + self.conn.download_file, + self.url, + self.remote_path, + ) + + def test_write_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.write_file(self.remote_path, b"file content") + self.conn.exec_ps_command.assert_called_once_with( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (self.remote_path, "ZmlsZSBjb250ZW50"), + ignore_stdout=True, + ) + + def test_format_windows_command_quotes_spaces(self): + result = windows_ssh._format_windows_command( + "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] + ) + self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') + + def test_format_windows_command_quotes_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh._format_windows_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant "%s"' % grant, + ) + + def test_build_ssh_exec_command_wraps_cmd_and_quotes_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh._build_ssh_exec_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "icacls.exe ' + 'F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant ""%s"""' % grant, + ) + + def test_build_ssh_exec_command_dism_image_trailing_backslash(self): + result = windows_ssh._build_ssh_exec_command( + "dism.exe", ["/Get-Drivers", "/image:F:\\"] + ) + self.assertEqual( + result, + 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', + ) + + def test_build_ssh_exec_command_dism_add_driver_no_nested_quotes(self): + driver = "G:\\Balloon\\2k19\\amd64" + result = windows_ssh._build_ssh_exec_command( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + "/driver:%s" % driver, + "/recurse", + "/forceunsigned", + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "C:\\Windows\\System32\\dism.exe /add-driver ' + '/image:F:\\ /driver:G:\\Balloon\\2k19\\amd64 /recurse ' + '/forceunsigned"', + ) + self.assertNotIn("/driver:\"", result) + + def test_split_on_marker_line(self): + buf = b"True\r\nCORIOLIS_PS_DONE_abc:0\r\nleftover" + parsed = windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") + self.assertEqual(parsed[0], b"True\r\n") + self.assertEqual(parsed[1], "CORIOLIS_PS_DONE_abc:0") + self.assertEqual(parsed[2], b"leftover") + + def test_split_on_marker_line_incomplete(self): + buf = b"True\r\nCORIOLIS_PS_DONE_abc:0" + self.assertIsNone( + windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") + ) + + def test_strip_ps_output_trims_blank_lines(self): + self.assertEqual(windows_ssh._strip_ps_output("\r\nG\r\n"), "G") + self.assertEqual(windows_ssh._strip_ps_output("True\r\n"), "True") + + def test_ps_session_is_alive(self): + channel = mock.Mock() + channel.closed = False + channel.exit_status_ready.return_value = False + transport = mock.Mock() + transport.is_active.return_value = True + self.conn._ssh.get_transport.return_value = transport + self.conn._ps_channel = channel + self.assertTrue(self.conn._ps_session_is_alive()) + + def test_ps_session_is_not_alive_when_channel_closed(self): + channel = mock.Mock() + channel.closed = True + channel.exit_status_ready.return_value = False + transport = mock.Mock() + transport.is_active.return_value = True + self.conn._ssh.get_transport.return_value = transport + self.conn._ps_channel = channel + self.assertFalse(self.conn._ps_session_is_alive()) + + def test_ps_session_is_not_alive_when_ssh_dead(self): + self.conn._ssh.get_transport.return_value = None + self.conn._ps_channel = mock.Mock() + self.assertFalse(self.conn._ps_session_is_alive()) + + def test_ensure_ps_session_restarts_dead_session(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._restart_ps_session.assert_called_once_with() + + def test_ensure_ps_session_restarts_after_failed_alive_check(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=True) + self.conn._should_probe_alive = mock.Mock(return_value=True) + self.conn._ping_ps_session = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._ping_ps_session.assert_called_once_with() + self.conn._restart_ps_session.assert_called_once_with() + + def test_ensure_ps_session_skips_restart_when_alive(self): + self.conn._ps_session_is_alive = mock.Mock(return_value=True) + self.conn._should_probe_alive = mock.Mock(return_value=False) + self.conn._restart_ps_session = mock.Mock() + self.conn._ensure_ps_session() + self.conn._restart_ps_session.assert_not_called() + + @mock.patch.object(windows_ssh.time, "monotonic") + def test_should_probe_alive_after_idle_interval(self, mock_monotonic): + mock_monotonic.return_value = 100.0 + self.conn._ps_last_alive = 100.0 - windows_ssh.PS_ALIVE_CHECK_INTERVAL + self.assertTrue(self.conn._should_probe_alive()) + self.conn._ps_last_alive = 90.0 + self.assertFalse(self.conn._should_probe_alive()) + + def test_build_ps_wrapper_uses_encoded_command(self): + wrapper = self.conn._build_ps_wrapper("Test-Path", "abc") + self.assertIn("CORIOLIS_PS_DONE_abc", wrapper) + self.assertIn("Invoke-Expression", wrapper) + self.assertIn("$ProgressPreference = 'SilentlyContinue'", wrapper) + self.assertIn("VABlAHMAdAAtAFAAYQB0AGgA", wrapper) + + def test_start_ps_session_waits_for_ready(self): + stdin = mock.Mock() + stdout = mock.Mock() + stdout.channel = mock.Mock() + self.conn._ssh.exec_command.return_value = (stdin, stdout, mock.Mock()) + self.conn._read_until_marker = mock.Mock( + return_value=("", "", "CORIOLIS_PS_READY") + ) + self.conn._host = "10.0.0.1" + self.conn._port = 22 + + self.conn._start_ps_session() + + self.conn._ssh.exec_command.assert_called_once_with( + windows_ssh._PS_START_COMMAND, + timeout=float(self.conn._conn_timeout), + ) + stdin.write.assert_called() + self.conn._read_until_marker.assert_called_once_with( + windows_ssh._PS_READY_MARKER, timeout=windows_ssh.PS_START_TIMEOUT + ) + self.assertIs(self.conn._ps_stdin, stdin) + self.assertIs(self.conn._ps_channel, stdout.channel) + + def test_read_until_marker(self): + channel = mock.Mock() + channel.recv_stderr_ready.return_value = False + channel.exit_status_ready.return_value = False + channel.recv_ready.side_effect = [True, False] + channel.recv.side_effect = [b"True\r\nCORIOLIS_PS_DONE_abc:0\r\n"] + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + + stdout, stderr, line = self.conn._read_until_marker( + "CORIOLIS_PS_DONE_abc", timeout=5 + ) + + self.assertEqual(stdout, "True\r\n") + self.assertEqual(stderr, "") + self.assertEqual(line, "CORIOLIS_PS_DONE_abc:0") + + def test_read_until_marker_drains_stderr_progress(self): + channel = mock.Mock() + channel.exit_status_ready.return_value = False + channel.recv_stderr_ready.side_effect = [True, False] + channel.recv_stderr.return_value = b"progress" + channel.recv_ready.side_effect = [True, False] + channel.recv.side_effect = [b"CORIOLIS_PS_DONE_abc:0\r\n"] + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + + stdout, stderr, line = self.conn._read_until_marker( + "CORIOLIS_PS_DONE_abc", timeout=5 + ) + + self.assertEqual(stdout, "") + self.assertEqual(stderr, "progress") + self.assertEqual(line, "CORIOLIS_PS_DONE_abc:0") + + def test_read_until_marker_timeout(self): + channel = mock.Mock() + channel.recv_stderr_ready.return_value = False + channel.exit_status_ready.return_value = False + channel.recv.side_effect = socket.timeout + self.conn._ps_channel = channel + self.conn._ps_stdout_buf = bytearray() + self.assertRaises( + exception.OSMorphingSSHOperationTimeout, + self.conn._read_until_marker, + "CORIOLIS_PS_DONE_abc", + timeout=0, + ) + + def test_invoke_persistent_ps(self): + self.conn._ensure_ps_session = mock.Mock() + self.conn._write_ps_stdin = mock.Mock() + self.conn._read_until_marker = mock.Mock( + return_value=("True\r\n", "", "CORIOLIS_PS_DONE_abc:0") + ) + with mock.patch.object(windows_ssh.uuid, "uuid4") as mock_uuid: + mock_uuid.return_value.hex = "abc" + stdout, stderr, exit_code = self.conn._invoke_persistent_ps("Test-Path") + + self.conn._ensure_ps_session.assert_called_once_with() + self.conn._write_ps_stdin.assert_called_once() + self.assertEqual(stdout, "True\r\n") + self.assertEqual(stderr, "") + self.assertEqual(exit_code, 0) + + def test_configure_ssh_keepalive(self): + transport = mock.Mock() + self.conn._ssh.get_transport.return_value = transport + self.conn._configure_ssh_keepalive() + transport.set_keepalive.assert_called_once_with( + windows_ssh.SSH_KEEPALIVE_INTERVAL + ) diff --git a/coriolis/tests/test_wsman.py b/coriolis/tests/test_wsman.py index 93206e6b2..6cbb5c410 100644 --- a/coriolis/tests/test_wsman.py +++ b/coriolis/tests/test_wsman.py @@ -1,239 +1,17 @@ -# Copyright 2023 Cloudbase Solutions Srl +# Copyright 2026 Cloudbase Solutions Srl # All Rights Reserved. -import logging -from unittest import mock - -import requests -from winrm import protocol - from coriolis import exception, wsman from coriolis.tests import test_base -class WSManConnectionTestCase(test_base.CoriolisBaseTestCase): - """Test suite for the Coriolis WSManConnection class.""" - - def setUp(self): - super(WSManConnectionTestCase, self).setUp() - self.conn = wsman.WSManConnection() - self.conn._protocol = mock.Mock() - self.conn._conn_timeout = 10 - self.cmd = "test_cmd" - self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] - self.sanitized_cmd = "test_cmd -RecoveryPassword '***'" - self.url = "http://example.com/file" - self.remote_path = "/remote/path" - - def test__init__timeout(self): - self.connection = wsman.WSManConnection() - self.assertEqual(self.connection._conn_timeout, wsman.DEFAULT_TIMEOUT) - - def test__init__timeout_set(self): - self.connection = wsman.WSManConnection(timeout=100) - self.assertEqual(self.connection._conn_timeout, 100) - - @mock.patch.object(protocol, 'Protocol') - def test_connect(self, mock_protocol): - self.conn.connect('url', 'username', cert_pem='test_cert') - mock_protocol.assert_called_once_with( - endpoint='url', - transport='ssl', - username='username', - password=None, - cert_pem="test_cert", - cert_key_pem=None, - ) - - @mock.patch.object(protocol, 'Protocol') - def test_connect_no_auth(self, mock_protocol): - self.conn.connect('url', 'username') - mock_protocol.assert_called_once_with( - endpoint='url', - transport='plaintext', - username='username', - password=None, - cert_pem=None, - cert_key_pem=None, - ) - - @mock.patch.object(wsman.WSManConnection, 'connect') - @mock.patch('coriolis.utils.wait_for_port_connectivity') - def test_from_connection_info(self, mock_wait_for_port_connectivity, mock_connect): - connection_info = { - "ip": "127.0.0.1", - "username": "user", - "password": "pass", - } - result = self.conn.from_connection_info(connection_info) - mock_wait_for_port_connectivity.assert_called_once_with("127.0.0.1", 5986) - mock_connect.assert_called_once_with( - url="https://127.0.0.1:5986/wsman", - username="user", - password="pass", - cert_pem=None, - cert_key_pem=None, - ) - self.assertIsInstance(result, self.conn.__class__) - - def test_from_connection_info_missing_keys(self): - self.assertRaises( - ValueError, - self.conn.from_connection_info, - {"username": "user", "password": "pass"}, - ) - - def test_from_connection_info_invalid_type(self): - self.assertRaises( - ValueError, self.conn.from_connection_info, 'invalid-connection-type' - ) - - def test_disconnect(self): - self.conn.disconnect() - self.assertIsNone(self.conn._protocol) - - def test_set_timeout(self): - self.conn.set_timeout(self.conn._conn_timeout) - self.assertEqual(self.conn._protocol.transport.timeout, self.conn._conn_timeout) - self.assertEqual(self.conn._protocol.timeout, self.conn._conn_timeout) - - def test__exec_command(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) - std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) - self.assertEqual(std_out, "std_out") - self.assertEqual(std_err, "std_err") - self.assertEqual(exit_code, 0) - - self.conn._protocol.open_shell.assert_called_once_with( - codepage=wsman.CODEPAGE_UTF8 - ) - shell_id = self.conn._protocol.open_shell.return_value - self.conn._protocol.run_command.assert_called_once_with( - shell_id, self.cmd, self.args - ) - command_id = self.conn._protocol.run_command.return_value - self.conn._protocol.get_command_output.assert_called_once_with( - shell_id, command_id - ) - self.conn._protocol.cleanup_command.assert_called_once_with( - shell_id, command_id - ) - self.conn._protocol.close_shell.assert_called_once_with(shell_id) - - def test__exec_command_exception(self): - self.conn._protocol.get_command_output.side_effect = ( - requests.exceptions.ReadTimeout - ) - self.assertRaises( - exception.OSMorphingWinRMOperationTimeout, - self.conn._exec_command, - self.cmd, - self.args, - ) - self.conn._protocol.cleanup_command.assert_called_once_with(mock.ANY, mock.ANY) - self.conn._protocol.close_shell.assert_called_once_with(mock.ANY) - - @mock.patch("time.sleep") - def test__exec_command_invalid_credentials(self, mock_sleep): - self.conn._protocol.open_shell.side_effect = ( - wsman.winrm_exceptions.InvalidCredentialsError - ) - +class WSManShimTestCase(test_base.CoriolisBaseTestCase): + def test_from_connection_info_raises(self): self.assertRaises( - exception.NotAuthorized, self.conn._exec_command, self.cmd, self.args + exception.InvalidInput, + wsman.WSManConnection.from_connection_info, + {"ip": "10.0.0.1", "username": "admin", "password": "x"}, ) - self.conn._protocol.close_shell.assert_not_called() - def test_exec_command(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) - exp_sanitized_log = ( - "DEBUG:coriolis.wsman:Executing WSMAN command: %s" % self.sanitized_cmd - ) - with self.assertLogs("coriolis.wsman", level=logging.DEBUG) as log_cm: - std_out = self.conn.exec_command(self.cmd, self.args) - self.assertEqual(std_out, "std_out") - self.assertIn(exp_sanitized_log, log_cm.output) - - def test_exec_command_exception(self): - self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 1) - self.assertRaises( - exception.CoriolisException, self.conn.exec_command, self.cmd, self.args - ) - - def test_exec_ps_command(self): - self.conn.exec_command = mock.Mock() - self.conn.exec_command.return_value = "std_out\n\n" - result = self.conn.exec_ps_command( - self.cmd, - include_stderr=False, - ) - self.conn.exec_command.assert_called_once_with( - "powershell.exe", - [ - "-EncodedCommand", - 'dABlAHMAdABfAGMAbQBkAA==', - '-NonInteractive', - '-ExecutionPolicy', - 'RemoteSigned', - ], - timeout=None, - sanitizable=False, - include_stderr=False, - ) - self.assertEqual(result, "std_out") - - def test_exec_ps_command_with_stderr(self): - self.conn.exec_command = mock.Mock() - self.conn.exec_command.return_value = "std_out\n\n", "stderr" - result = self.conn.exec_ps_command( - self.cmd, - include_stderr=True, - ) - self.conn.exec_command.assert_called_once_with( - "powershell.exe", - [ - "-EncodedCommand", - 'dABlAHMAdABfAGMAbQBkAA==', - '-NonInteractive', - '-ExecutionPolicy', - 'RemoteSigned', - ], - timeout=None, - sanitizable=False, - include_stderr=True, - ) - self.assertEqual(result, ("std_out", "stderr")) - - def test_test_path(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.exec_ps_command.return_value = "True" - result = self.conn.test_path("test_path") - self.conn.exec_ps_command.assert_called_once_with( - "Test-Path -Path \"test_path\"" - ) - self.assertTrue(result) - - def test_download_file(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.download_file(self.url, self.remote_path) - self.conn.exec_ps_command.assert_called_once() - - def test_download_file_exception(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.exec_ps_command.side_effect = exception.CoriolisException - self.assertRaises( - exception.CoriolisException, - self.conn.download_file, - self.url, - self.remote_path, - ) - self.conn.exec_ps_command.assert_called_once() - - def test_write_file(self): - self.conn.exec_ps_command = mock.Mock() - self.conn.write_file(self.remote_path, b'file content') - self.conn.exec_ps_command.assert_called_once_with( - "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" - % (self.remote_path, 'ZmlsZSBjb250ZW50'), - ignore_stdout=True, - ) + def test_constructor_raises(self): + self.assertRaises(exception.InvalidInput, wsman.WSManConnection) diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py new file mode 100644 index 000000000..865c02b2d --- /dev/null +++ b/coriolis/windows_ssh.py @@ -0,0 +1,634 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""SSH connection for Windows OS morphing minions. + +The Coriolis worker connects to the minion over OpenSSH. It does not open +WinRM. Morphing commands still run as powershell.exe, diskpart, reg.exe, +and DISM. + +Minion requirements +------------------- +The Windows morphing minion must provide all of the following: + +* Windows Server 2019 or later. +* OpenSSH Server listening on TCP port 22. A custom SSH port is allowed + when connection_info['port'] is that port. +* Windows PowerShell 5.1. The executable must be powershell.exe on PATH + for the SSH user. +* An SSH login that can run elevated morphing commands. Providers usually + send Administrator. Auth is a password or an SSH private key. +* The SSH user must start powershell.exe with -Command -. The process + reads commands from stdin. PowerShell 7 (pwsh) is not required. +* diskpart.exe, reg.exe, and DISM must be available. These tools are + present on Windows Server. +* The Coriolis worker must reach the minion IP on the SSH port. + +Not required +------------ +* WinRM, HTTPS port 5986, or a WinRM listener. +* PowerShell 7, pwsh.exe, or an OpenSSH Subsystem powershell line. +* PSRP remoting (Enter-PSSession -HostName, pypsrp SSH). +""" + +import base64 +import socket +import time +import uuid + +import paramiko +from oslo_log import log as logging +from oslo_utils import strutils + +from coriolis import exception, utils + +LOG = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 3600 +SSH_PORT = 22 +PS_ALIVE_CHECK_INTERVAL = 30 +PS_PING_TIMEOUT = 15 +PS_START_TIMEOUT = 60 +SSH_KEEPALIVE_INTERVAL = 30 +_PS_READY_MARKER = "CORIOLIS_PS_READY" +_PS_START_COMMAND = ( + "powershell.exe -NoLogo -NoProfile -NonInteractive " + "-ExecutionPolicy RemoteSigned -OutputFormat Text -Command -" +) +_PS_BOOTSTRAP = ( + "$ProgressPreference = 'SilentlyContinue'; " + "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false; " + "[Console]::InputEncoding = New-Object System.Text.UTF8Encoding $false; " + "$OutputEncoding = [Console]::OutputEncoding; " + "$ErrorActionPreference = 'Continue'; " + "Write-Output '%s'\r\n" % _PS_READY_MARKER +) + + +def _is_reg_exe(cmd): + base = str(cmd).replace("\\", "/").rsplit("/", 1)[-1].lower() + return base in ("reg", "reg.exe") + + +_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") + + +def _quote_cmd_arg(part): + part_str = str(part) + if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): + return '"%s"' % part_str.replace('"', '""') + return part_str + + +def _format_windows_command(cmd, args): + return " ".join(_quote_cmd_arg(p) for p in [cmd] + list(args or [])) + + +def _escape_trailing_backslash_for_ssh(command): + """Keep a trailing backslash from eating the SSH quote. + + Windows OpenSSH wraps the exec string in double quotes. An odd number + of trailing backslashes escapes that quote. PowerShell then reports a + missing string terminator. DISM /image:F:\\ is the usual case. + """ + n = len(command) - len(command.rstrip("\\")) + if n % 2 == 1: + return command + "\\" + return command + + +def _wrap_native_command_for_ssh(command): + """Run native tools via cmd.exe. + + OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in + icacls grants as a command. cmd.exe does not when the grant is quoted. + """ + command = _escape_trailing_backslash_for_ssh(command) + return 'cmd.exe /c "%s"' % command.replace('"', '""') + + +def _build_ssh_exec_command(cmd, args): + formatted = _format_windows_command(cmd, args) + wrapped = _wrap_native_command_for_ssh(formatted) + return _escape_trailing_backslash_for_ssh(wrapped) + + +def _strip_ps_output(stdout): + return (stdout or "").strip() + + +def _drain_ssh_channel(channel, stdout_buf, stderr_buf): + """Read available SSH bytes. Do not block on one stream. + + Cmdlets such as Expand-Archive write progress to stderr. A blocking + stdout read leaves that stderr unread. The SSH window fills and the + remote process waits forever. + """ + got = False + while channel.recv_stderr_ready(): + chunk = channel.recv_stderr(65536) + if not chunk: + break + stderr_buf.extend(chunk) + got = True + while channel.recv_ready(): + chunk = channel.recv(65536) + if not chunk: + break + stdout_buf.extend(chunk) + got = True + return got + + +def _split_on_marker_line(buf, marker): + """Return (before, line, after) when a full marker line is present.""" + marker_bytes = marker.encode("ascii") + idx = buf.find(marker_bytes) + if idx < 0: + return None + rest = buf[idx:] + nl = rest.find(b"\n") + if nl < 0: + return None + line = rest[:nl].rstrip(b"\r").decode("ascii", errors="replace") + before = buf[:idx] + after = rest[nl + 1 :] + return before, line, after + + +class WindowsSSHConnection(object): + """Windows morphing minion connection over SSH. + + See the module docstring for minion requirements. PowerShell cmdlets + reuse one remote powershell.exe process. Native commands still use one + SSH exec per call. + """ + + EOL = "\r\n" + + def __init__(self, timeout=None): + self._ssh = None + self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) + self._host = None + self._port = SSH_PORT + self._username = None + self._password = None + self._pkey = None + self._ps_stdin = None + self._ps_channel = None + self._ps_stdout_buf = bytearray() + self._ps_last_alive = 0.0 + + @classmethod + def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): + """Return a WindowsSSHConnection for the provided conn info.""" + if not isinstance(connection_info, dict): + raise ValueError( + "Windows minion connection must be a dict. Got type '%s', " + "value: %s" % (type(connection_info), connection_info) + ) + + required_keys = ["ip", "username"] + missing = [key for key in required_keys if key not in connection_info] + if missing: + raise ValueError( + "The following keys were missing from Windows SSH connection " + "info %s. Got: %s" % (missing, connection_info) + ) + if not connection_info.get("password") and not connection_info.get("pkey"): + raise ValueError( + "Windows SSH connection info must include password or pkey. " + "Got: %s" % connection_info + ) + + host = connection_info["ip"] + port = connection_info.get("port") or SSH_PORT + username = connection_info["username"] + password = connection_info.get("password") + pkey = connection_info.get("pkey") + + LOG.info( + "Waiting for SSH connectivity on host: %(host)s:%(port)s", + {"host": host, "port": port}, + ) + utils.wait_for_port_connectivity(host, port) + + conn = cls(timeout) + conn.connect( + host=host, + port=port, + username=username, + password=password, + pkey=pkey, + ) + return conn + + def connect(self, host, port, username, password=None, pkey=None): + self._host = host + self._port = port + self._username = username + self._password = password + self._pkey = pkey + try: + self._open_ssh() + except exception.CoriolisException as ex: + if isinstance(ex.__cause__, paramiko.AuthenticationException): + raise exception.NotAuthorized( + message="The SSH connection credentials are invalid. " + "If you are using a template with a default " + "pre-baked username/password, please ensure " + "that you have passed the credentials to the " + "destination Coriolis plugin you have selected," + " either via the Target Environment parameters " + "set when creating the Migration/Replica, or " + "by setting it in the destination plugin's " + "dedicated section of the coriolis.conf " + "static configuration file." + ) from ex + raise + self._start_ps_session() + + def _open_ssh(self): + self._ssh = utils.connect_ssh( + hostname=self._host, + port=self._port, + username=self._username, + password=self._password, + pkey=self._pkey, + ) + self._ssh.set_log_channel("paramiko.morpher.%s.%s" % (self._host, self._port)) + self._configure_ssh_keepalive() + + def _configure_ssh_keepalive(self): + if not self._ssh: + return + transport = self._ssh.get_transport() + if transport: + transport.set_keepalive(SSH_KEEPALIVE_INTERVAL) + + def _ssh_is_alive(self): + if not self._ssh: + return False + transport = self._ssh.get_transport() + return bool(transport and transport.is_active()) + + def _ps_session_is_alive(self): + if not self._ssh_is_alive(): + return False + channel = self._ps_channel + if channel is None: + return False + if channel.closed: + return False + if channel.exit_status_ready(): + return False + return True + + def _should_probe_alive(self): + if not self._ps_last_alive: + return True + idle = time.monotonic() - self._ps_last_alive + return idle >= PS_ALIVE_CHECK_INTERVAL + + def _close_ps_session(self, wait=False, wait_timeout=10): + stdin = self._ps_stdin + channel = self._ps_channel + self._ps_stdin = None + self._ps_channel = None + self._ps_stdout_buf = bytearray() + if stdin is not None: + try: + stdin.write(b"exit\r\n") + stdin.flush() + except Exception: + pass + try: + stdin.close() + except Exception: + pass + if wait and channel is not None: + deadline = time.monotonic() + int(wait_timeout) + while time.monotonic() < deadline: + try: + if channel.exit_status_ready(): + break + except Exception: + break + time.sleep(0.05) + if channel is not None: + try: + channel.close() + except Exception: + pass + + def _reconnect_ssh(self): + if self._ssh: + try: + self._ssh.close() + except Exception: + pass + self._ssh = None + LOG.warning( + "Reconnecting SSH to Windows minion %(host)s:%(port)s", + {"host": self._host, "port": self._port}, + ) + utils.wait_for_port_connectivity(self._host, self._port) + self._open_ssh() + + def _restart_ps_session(self): + self._close_ps_session() + if not self._ssh_is_alive(): + self._reconnect_ssh() + self._start_ps_session() + + def _start_ps_session(self): + self._close_ps_session() + LOG.info( + "Starting persistent PowerShell session on %(host)s:%(port)s", + {"host": self._host, "port": self._port}, + ) + try: + stdin, stdout, _stderr = self._ssh.exec_command( + _PS_START_COMMAND, timeout=float(self._conn_timeout) + ) + self._ps_stdin = stdin + self._ps_channel = stdout.channel + self._ps_stdout_buf = bytearray() + self._write_ps_stdin(_PS_BOOTSTRAP.encode("utf-8")) + self._read_until_marker(_PS_READY_MARKER, timeout=PS_START_TIMEOUT) + self._ps_last_alive = time.monotonic() + except Exception: + self._close_ps_session() + raise + + def _write_ps_stdin(self, data): + try: + self._ps_stdin.write(data) + self._ps_stdin.flush() + except (OSError, EOFError, socket.timeout, paramiko.SSHException) as ex: + self._close_ps_session() + raise exception.CoriolisException( + "PowerShell SSH session write failed: %s" % ex + ) from ex + + def _read_until_marker(self, marker, timeout): + deadline = time.monotonic() + int(timeout) + stderr_buf = bytearray() + channel = self._ps_channel + if channel is None: + raise exception.CoriolisException( + "PowerShell SSH session is not connected." + ) + channel.settimeout(0.1) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise exception.OSMorphingSSHOperationTimeout( + cmd=marker, timeout=timeout + ) + try: + _drain_ssh_channel(channel, self._ps_stdout_buf, stderr_buf) + except socket.timeout: + pass + + parsed = _split_on_marker_line(self._ps_stdout_buf, marker) + if parsed is not None: + before, line, after = parsed + self._ps_stdout_buf = bytearray(after) + stdout = before.decode("utf-8", errors="replace") + stderr = stderr_buf.decode("utf-8", errors="replace") + return stdout, stderr, line + + if channel.exit_status_ready() and not channel.recv_ready(): + leftover = self._ps_stdout_buf.decode("utf-8", errors="replace") + raise exception.CoriolisException( + "PowerShell SSH session closed while waiting for " + "marker '%s'. Output: %s" % (marker, leftover) + ) + time.sleep(0.05) + + def _ping_ps_session(self): + token = uuid.uuid4().hex + marker = "CORIOLIS_PS_ALIVE_%s" % token + try: + self._write_ps_stdin(("Write-Output '%s'\r\n" % marker).encode("utf-8")) + _stdout, _stderr, line = self._read_until_marker( + marker, timeout=PS_PING_TIMEOUT + ) + alive = line.startswith(marker) + if alive: + self._ps_last_alive = time.monotonic() + return alive + except Exception: + LOG.debug( + "PowerShell SSH alive check failed: %s", + utils.get_exception_details(), + ) + return False + + def _ensure_ps_session(self): + if self._ps_session_is_alive(): + if self._should_probe_alive() and not self._ping_ps_session(): + LOG.warning( + "PowerShell SSH session failed the alive check. " + "Starting a new session." + ) + self._restart_ps_session() + return + LOG.warning("PowerShell SSH session is not alive. Starting a new session.") + self._restart_ps_session() + + def _release_ps_registry_handles(self): + """Stop PowerShell so it does not hold loaded hive keys. + + Get-ItemProperty keeps RegistryKey objects in this process. + Garbage collection does not drop those handles. End the process + before reg.exe load or unload. + """ + self._close_ps_session(wait=True) + + def _build_ps_wrapper(self, cmd, token): + encoded_cmd = base64.b64encode(cmd.encode("utf-16le")).decode() + marker = "CORIOLIS_PS_DONE_%s" % token + return ( + "$ProgressPreference = 'SilentlyContinue'; " + "$__c = [System.Text.Encoding]::Unicode.GetString(" + "[Convert]::FromBase64String('%s')); " + "$__e = 0; " + "try { Invoke-Expression -Command $__c } " + "catch { Write-Error -ErrorRecord $_; $__e = 1 }; " + "Write-Output ('%s:' + $__e)\r\n" % (encoded_cmd, marker) + ) + + @utils.retry_on_error( + terminal_exceptions=[ + exception.NotAuthorized, + exception.OSMorphingSSHOperationTimeout, + ] + ) + def _invoke_persistent_ps(self, cmd, timeout=None): + timeout = int(timeout or self._conn_timeout) + self._ensure_ps_session() + token = uuid.uuid4().hex + marker = "CORIOLIS_PS_DONE_%s" % token + wrapper = self._build_ps_wrapper(cmd, token) + self._write_ps_stdin(wrapper.encode("utf-8")) + stdout, stderr, line = self._read_until_marker(marker, timeout=timeout) + self._ps_last_alive = time.monotonic() + prefix = "%s:" % marker + if not line.startswith(prefix): + raise exception.CoriolisException( + "PowerShell SSH session returned an invalid status line: %s" % line + ) + try: + exit_code = int(line[len(prefix) :].strip()) + except ValueError as ex: + raise exception.CoriolisException( + "PowerShell SSH session returned a non-numeric exit code: %s" % line + ) from ex + return stdout, stderr, exit_code + + def disconnect(self): + self._close_ps_session() + if self._ssh: + self._ssh.close() + self._ssh = None + + def set_timeout(self, timeout): + if timeout: + self._conn_timeout = int(timeout) + + def _read_ssh_exec_output(self, channel, timeout, sanitized_cmd): + """Read stdout and stderr until the SSH exec channel exits.""" + deadline = time.monotonic() + int(timeout) + stdout_buf = bytearray() + stderr_buf = bytearray() + channel.settimeout(0.1) + while True: + if time.monotonic() >= deadline: + raise exception.OSMorphingSSHOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) + try: + _drain_ssh_channel(channel, stdout_buf, stderr_buf) + except socket.timeout: + pass + if channel.exit_status_ready(): + try: + _drain_ssh_channel(channel, stdout_buf, stderr_buf) + except socket.timeout: + pass + if not channel.recv_ready() and not channel.recv_stderr_ready(): + break + time.sleep(0.05) + exit_code = channel.recv_exit_status() + stdout_str = stdout_buf.decode("utf-8", errors="replace") + stderr_str = stderr_buf.decode("utf-8", errors="replace") + return stdout_str, stderr_str, exit_code + + @utils.retry_on_error( + terminal_exceptions=[ + exception.NotAuthorized, + exception.OSMorphingSSHOperationTimeout, + ] + ) + def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): + command = _build_ssh_exec_command(cmd, args) + if sanitizable: + sanitized_cmd = strutils.mask_password(command) + else: + sanitized_cmd = "***" + + timeout = int(timeout or self._conn_timeout) + LOG.debug("Executing SSH command: %s", sanitized_cmd) + try: + _, stdout, _stderr = self._ssh.exec_command(command, timeout=float(timeout)) + return self._read_ssh_exec_output(stdout.channel, timeout, sanitized_cmd) + except socket.timeout as ex: + raise exception.OSMorphingSSHOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) from ex + + def exec_command( + self, + cmd, + args=[], + timeout=None, + sanitizable=True, + include_stderr=False, + ): + if sanitizable: + sanitized_cmd = strutils.mask_password(_build_ssh_exec_command(cmd, args)) + else: + sanitized_cmd = "***" + if _is_reg_exe(cmd): + self._release_ps_registry_handles() + LOG.debug("Executing Windows SSH command: %s", sanitized_cmd) + std_out, std_err, exit_code = self._exec_command( + cmd, args, timeout=timeout, sanitizable=sanitizable + ) + + if exit_code: + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) + ) + + if include_stderr: + return std_out, std_err + return std_out + + def exec_ps_command( + self, + cmd, + ignore_stdout=False, + timeout=None, + include_stderr=False, + ): + LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) + stdout, stderr, exit_code = self._invoke_persistent_ps(cmd, timeout=timeout) + if exit_code: + sanitized_cmd = strutils.mask_password(cmd) + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, stdout, stderr) + ) + if include_stderr: + return _strip_ps_output(stdout), stderr + return _strip_ps_output(stdout) + + def test_path(self, remote_path): + ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) + return ret_val == "True" + + def download_file(self, url, remote_path): + LOG.debug( + "Downloading: \"%(url)s\" to \"%(path)s\"", + {"url": url, "path": remote_path}, + ) + try: + self.exec_ps_command( + "[Net.ServicePointManager]::SecurityProtocol = " + "[Net.SecurityProtocolType]::Tls12;" + "if(!([System.Management.Automation.PSTypeName]'" + "System.Net.Http.HttpClient').Type) {$assembly = " + "[System.Reflection.Assembly]::LoadWithPartialName(" + "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." + "GetStreamAsync('%(url)s').Result.CopyTo(" + "(New-Object IO.FileStream '%(outfile)s', Create, Write, " + "None), 1MB)" % {"url": url, "outfile": remote_path}, + ignore_stdout=True, + ) + except exception.CoriolisException as ex: + LOG.trace(utils.get_exception_details()) + raise exception.CoriolisException( + "Failed to download file from URL: %s to path: %s. Please " + "check logs for more details." % (url, remote_path) + ) from ex + + def write_file(self, remote_path, content): + self.exec_ps_command( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (remote_path, base64.b64encode(content).decode()), + ignore_stdout=True, + ) diff --git a/coriolis/wsman.py b/coriolis/wsman.py index 27dd661a1..5fdab5b7e 100644 --- a/coriolis/wsman.py +++ b/coriolis/wsman.py @@ -1,248 +1,23 @@ -# Copyright 2016 Cloudbase Solutions Srl +# Copyright 2026 Cloudbase Solutions Srl # All Rights Reserved. -import base64 +"""Import shim for leftover providers that still import coriolis.wsman. -import requests -from oslo_log import log as logging -from oslo_utils import strutils -from winrm import exceptions as winrm_exceptions -from winrm import protocol +Windows morphing uses SSH. This module does not open WinRM. +Remove this module after the leftover providers stop importing it. +""" -from coriolis import exception, utils +from coriolis import exception -AUTH_BASIC = "basic" -AUTH_KERBEROS = "kerberos" -AUTH_CERTIFICATE = "certificate" - -CODEPAGE_UTF8 = 65001 -DEFAULT_TIMEOUT = 3600 - -LOG = logging.getLogger(__name__) +_WINRM_REMOVED_MSG = "Windows minion connections must use SSH. WinRM is not supported." class WSManConnection(object): - def __init__(self, timeout=None): - self._protocol = None - self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) - - EOL = "\r\n" - - @utils.retry_on_error() - def connect( - self, url, username, auth=None, password=None, cert_pem=None, cert_key_pem=None - ): - if not auth: - if cert_pem: - auth = AUTH_CERTIFICATE - else: - auth = AUTH_BASIC - - auth_transport_map = { - AUTH_BASIC: 'plaintext', - AUTH_KERBEROS: 'kerberos', - AUTH_CERTIFICATE: 'ssl', - } + """Raise on use. Leftover providers import this class at load time.""" - self._protocol = protocol.Protocol( - endpoint=url, - transport=auth_transport_map[auth], - username=username, - password=password, - cert_pem=cert_pem, - cert_key_pem=cert_key_pem, - ) + def __init__(self, timeout=None): + raise exception.InvalidInput(_WINRM_REMOVED_MSG) @classmethod - def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): - """Returns a wsman.WSManConnection obj for the provided conn info.""" - if not isinstance(connection_info, dict): - raise ValueError( - "WSMan connection must be a dict. Got type '%s', value: %s" - % (type(connection_info), connection_info) - ) - - required_keys = ["ip", "username", "password"] - missing = [key for key in required_keys if key not in connection_info] - if missing: - raise ValueError( - "The following keys were missing from WSMan connection " - "info %s. Got: %s" % (missing, connection_info) - ) - - host = connection_info["ip"] - port = connection_info.get("port", 5986) - username = connection_info["username"] - password = connection_info.get("password") - cert_pem = connection_info.get("cert_pem") - cert_key_pem = connection_info.get("cert_key_pem") - url = "https://%s:%s/wsman" % (host, port) - - LOG.info( - "Waiting for connectivity on host: %(host)s:%(port)s", - {"host": host, "port": port}, - ) - utils.wait_for_port_connectivity(host, port) - - conn = cls(timeout) - conn.connect( - url=url, - username=username, - password=password, - cert_pem=cert_pem, - cert_key_pem=cert_key_pem, - ) - - return conn - - def disconnect(self): - self._protocol = None - - def set_timeout(self, timeout): - if timeout: - self._protocol.timeout = timeout - self._protocol.transport.timeout = timeout - - @utils.retry_on_error( - terminal_exceptions=[ - winrm_exceptions.InvalidCredentialsError, - exception.OSMorphingWinRMOperationTimeout, - ] - ) - def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): - if sanitizable: - sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) - else: - sanitized_cmd = "***" - - timeout = int(timeout or self._conn_timeout) - self.set_timeout(timeout) - shell_id = None - try: - shell_id = self._protocol.open_shell(codepage=CODEPAGE_UTF8) - command_id = self._protocol.run_command(shell_id, cmd, args) - try: - (std_out, std_err, exit_code) = self._protocol.get_command_output( - shell_id, command_id - ) - except requests.exceptions.ReadTimeout: - raise exception.OSMorphingWinRMOperationTimeout( - cmd=sanitized_cmd, timeout=timeout - ) - finally: - self._protocol.cleanup_command(shell_id, command_id) - - return (std_out, std_err, exit_code) - except winrm_exceptions.InvalidCredentialsError as ex: - raise exception.NotAuthorized( - message="The WinRM connection credentials are invalid. " - "If you are using a template with a default " - "pre-baked username/password, please ensure " - "that you have passed the credentials to the " - "destination Coriolis plugin you have selected," - " either via the Target Environment parameters " - "set when creating the Migration/Replica, or " - "by setting it in the destination plugin's " - "dedicated section of the coriolis.conf " - "static configuration file." - ) from ex - finally: - if shell_id: - self._protocol.close_shell(shell_id) - - def exec_command( - self, - cmd, - args=[], - timeout=None, - sanitizable=True, - include_stderr=False, - ): - # Our sanitization helpers do not work for base64 encoded commands, - # in which case we'll avoid logging it so that we won't leak - # sensitive information. - if sanitizable: - sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) - else: - sanitized_cmd = "***" - LOG.debug("Executing WSMAN command: %s", sanitized_cmd) - std_out, std_err, exit_code = self._exec_command( - cmd, args, timeout=timeout, sanitizable=sanitizable - ) - - if exit_code: - raise exception.CoriolisException( - "Command \"%s\" failed with exit code: %s\n" - "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) - ) - - if include_stderr: - return std_out, std_err - return std_out - - def exec_ps_command( - self, - cmd, - ignore_stdout=False, - timeout=None, - include_stderr=False, - ): - LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) - base64_cmd = base64.b64encode(cmd.encode('utf-16le')).decode() - ret = self.exec_command( - "powershell.exe", - [ - "-EncodedCommand", - base64_cmd, - "-NonInteractive", - "-ExecutionPolicy", - "RemoteSigned", - ], - timeout=timeout, - sanitizable=False, - include_stderr=include_stderr, - ) - if include_stderr: - stdout, stderr = ret - return stdout[:-2], stderr - else: - stdout = ret - return stdout[:-2] - - def test_path(self, remote_path): - ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) - return ret_val == "True" - - def download_file(self, url, remote_path): - LOG.debug( - "Downloading: \"%(url)s\" to \"%(path)s\"", - {"url": url, "path": remote_path}, - ) - try: - # Nano Server does not have Invoke-WebRequest and additionally - # this is also faster - self.exec_ps_command( - "[Net.ServicePointManager]::SecurityProtocol = " - "[Net.SecurityProtocolType]::Tls12;" - "if(!([System.Management.Automation.PSTypeName]'" - "System.Net.Http.HttpClient').Type) {$assembly = " - "[System.Reflection.Assembly]::LoadWithPartialName(" - "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." - "GetStreamAsync('%(url)s').Result.CopyTo(" - "(New-Object IO.FileStream '%(outfile)s', Create, Write, " - "None), 1MB)" % {"url": url, "outfile": remote_path}, - ignore_stdout=True, - ) - except exception.CoriolisException as ex: - LOG.trace(utils.get_exception_details()) - raise exception.CoriolisException( - "Failed to download file from URL: %s to path: %s. Please " - "check logs for more details." % (url, remote_path) - ) from ex - - def write_file(self, remote_path, content): - self.exec_ps_command( - "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" - % (remote_path, base64.b64encode(content).decode()), - ignore_stdout=True, - ) + def from_connection_info(cls, connection_info, timeout=None): + raise exception.InvalidInput(_WINRM_REMOVED_MSG) diff --git a/requirements.txt b/requirements.txt index 89d38ee77..89af2d979 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,6 @@ python-keystoneclient python-memcached>=1.56 python-barbicanclient python-swiftclient>=3.2 -git+https://github.com/cloudbase/pywinrm.git@requests#egg=pywinrm PyYAML redis requests From 410335f9b737d9365796d9e00bc5dfaed7e66bec Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Wed, 9 Sep 2026 14:48:44 +0300 Subject: [PATCH 2/6] Add back winrm as fallback --- coriolis/osmorphing/osmount/windows.py | 22 +- coriolis/osmorphing/windows.py | 12 +- coriolis/providers/provider_utils.py | 66 ++++- .../tests/osmorphing/osmount/test_windows.py | 12 +- coriolis/tests/osmorphing/test_windows.py | 10 +- .../tests/providers/test_provider_utils.py | 52 +++- coriolis/tests/test_windows_conn.py | 30 +++ coriolis/tests/test_windows_ssh.py | 103 +------- coriolis/tests/test_windows_ssh_cmd.py | 104 ++++++++ coriolis/tests/test_wsman.py | 238 ++++++++++++++++- coriolis/windows_conn.py | 30 +++ coriolis/windows_ssh.py | 93 ++----- coriolis/windows_ssh_cmd.py | 72 +++++ coriolis/wsman.py | 249 +++++++++++++++++- requirements.txt | 1 + 15 files changed, 863 insertions(+), 231 deletions(-) create mode 100644 coriolis/tests/test_windows_conn.py create mode 100644 coriolis/tests/test_windows_ssh_cmd.py create mode 100644 coriolis/windows_conn.py create mode 100644 coriolis/windows_ssh_cmd.py diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 0a8b1a6e0..15f51322c 100644 --- a/coriolis/osmorphing/osmount/windows.py +++ b/coriolis/osmorphing/osmount/windows.py @@ -6,7 +6,7 @@ from oslo_log import log as logging -from coriolis import constants, exception, utils, windows_ssh +from coriolis import constants, exception, utils, windows_conn from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -21,13 +21,21 @@ def __init__(self, *args, **kwargs): self._unlocked_volumes: list[str] = [] def _connect(self): - host = self._connection_info["ip"] - self._event_manager.progress_update( - "Connecting through SSH to OSMorphing host on: %s" % host - ) + connection_info = self._connection_info + host = connection_info["ip"] + if windows_conn.uses_winrm(connection_info): + port = connection_info.get("port", windows_conn.WINRM_HTTPS_PORT) + self._event_manager.progress_update( + "Connecting to WinRM host: %(host)s:%(port)s" + % {"host": host, "port": port} + ) + else: + self._event_manager.progress_update( + "Connecting through SSH to OSMorphing host on: %s" % host + ) - self._conn = windows_ssh.WindowsSSHConnection.from_connection_info( - self._connection_info, self._osmount_operation_timeout + self._conn = windows_conn.from_connection_info( + connection_info, self._osmount_operation_timeout ) def get_connection(self): diff --git a/coriolis/osmorphing/windows.py b/coriolis/osmorphing/windows.py index 443a77106..1bafd766b 100644 --- a/coriolis/osmorphing/windows.py +++ b/coriolis/osmorphing/windows.py @@ -272,14 +272,12 @@ def _add_dism_driver(self, driver_path): LOG.info("Adding driver: %s" % driver_path) dism_path = self._get_dism_path() try: - # Pass a raw /driver: path. Nested quotes become part of the - # path after SSH wraps the command in cmd.exe /c. return self._conn.exec_command( dism_path, [ "/add-driver", "/image:%s" % self._os_root_dir, - "/driver:%s" % driver_path, + "/driver:\"%s\"" % driver_path, "/recurse", "/forceunsigned", ], @@ -306,7 +304,7 @@ def _add_dism_driver(self, driver_path): def _mount_disk_image(self, path): LOG.info("Mounting disk image: %s" % path) drive_letter, stderr = self._conn.exec_ps_command( - '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' % path, + "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % path, include_stderr=True, ) if not drive_letter: @@ -343,7 +341,6 @@ def _expand_archive(self, path, destination, overwrite=True): self._conn.exec_ps_command("rm -recurse -force %s" % destination) self._conn.exec_ps_command( - "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": path, "destination": destination}, @@ -1027,9 +1024,8 @@ def _setup_qemu_agent_installation_local_script( ) else: self._conn.exec_ps_command( - "Copy-Item -Force '%s' -Destination '%s'" - % (msi_source_path, msi_dest_path), - ignore_stdout=True, + "Copy-Item '%s' -Destination '%s'" + % (msi_source_path, msi_dest_path) ) local_script = QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { "agent_msi_path": "%s\\%s" diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index f05637c00..009678519 100644 --- a/coriolis/providers/provider_utils.py +++ b/coriolis/providers/provider_utils.py @@ -6,7 +6,7 @@ import requests from oslo_log import log as logging -from coriolis import constants, exception, utils +from coriolis import constants, exception, utils, windows_conn, wsman LOG = logging.getLogger(__name__) @@ -205,9 +205,41 @@ def _poll_instance_until_reachable_ssh( ) +def _poll_instance_until_reachable_winrm( + connection_info: dict, + timeout: int = 600, + poll_interval: int = 10, +): + start = time.time() + while (time.time() - start) < timeout: + try: + conn = wsman.WSManConnection.from_connection_info(connection_info) + conn.exec_ps_command("whoami") + return + except Exception as ex: + LOG.debug( + f"Could not conect to Windows host: {str(ex)}. " + f"Retrying, time left: {timeout - (time.time() - start)}." + ) + time.sleep(poll_interval) + + raise exception.CoriolisException( + f"Operation timed out after waiting {timeout}s for Windows host to " + f"be accessible via WinRM." + ) + + +def _protocol_from_connection_info(connection_info, protocol): + if protocol: + return protocol + if windows_conn.uses_winrm(connection_info): + return constants.PROTOCOL_WINRM + return constants.PROTOCOL_SSH + + def poll_instance_until_reachable( connection_info: dict, - protocol: str = constants.PROTOCOL_SSH, + protocol: str = None, timeout: int = 600, poll_interval: int = 10, ) -> paramiko.SSHClient: @@ -219,19 +251,27 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol. Only "ssh" is supported. + :param protocol: connection protocol, "ssh" or "winrm". If omitted, + port 5986 selects WinRM. Any other port selects SSH. :param timeout: the maximum amount of time to wait :param poll_interval: the amount of time to wait between retries """ - if protocol != constants.PROTOCOL_SSH: - raise exception.InvalidInput( - f"Unsupported instance connection protocol: {protocol}" + resolved = _protocol_from_connection_info(connection_info, protocol) + if resolved == constants.PROTOCOL_SSH: + ssh_connection_info = dict(connection_info) + if ssh_connection_info.get("port") is None: + ssh_connection_info["port"] = 22 + return _poll_instance_until_reachable_ssh( + connection_info=ssh_connection_info, + timeout=timeout, + poll_interval=poll_interval, + ) + if resolved == constants.PROTOCOL_WINRM: + return _poll_instance_until_reachable_winrm( + connection_info=connection_info, + timeout=timeout, + poll_interval=poll_interval, ) - ssh_connection_info = dict(connection_info) - if ssh_connection_info.get("port") is None: - ssh_connection_info["port"] = 22 - return _poll_instance_until_reachable_ssh( - connection_info=ssh_connection_info, - timeout=timeout, - poll_interval=poll_interval, + raise exception.InvalidInput( + f"Unsupported instance connection protocol: {resolved}" ) diff --git a/coriolis/tests/osmorphing/osmount/test_windows.py b/coriolis/tests/osmorphing/osmount/test_windows.py index 71fc00c72..7ac7f974b 100644 --- a/coriolis/tests/osmorphing/osmount/test_windows.py +++ b/coriolis/tests/osmorphing/osmount/test_windows.py @@ -24,7 +24,7 @@ class CoriolisTestException(Exception): class WindowsMountToolsTestCase(test_base.CoriolisBaseTestCase): """Test suite for the WindowsMountTools class.""" - @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + @mock.patch.object(windows.windows_conn, 'from_connection_info') def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() @@ -45,19 +45,13 @@ def setUp(self, mock_from_connection_info): ) self.tools._conn = mock.MagicMock() - @mock.patch.object(windows.windows_ssh.WindowsSSHConnection, 'from_connection_info') + @mock.patch.object(windows.windows_conn, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) - expected_conn_info = { - "ip": "127.0.0.1", - "username": "random_username", - "password": "random_password", - "pkey": "random_pkey", - } mock_from_connection_info.assert_called_once_with( - expected_conn_info, mock.sentinel.operation_timeout + self.conn_info, mock.sentinel.operation_timeout ) def test_get_connection(self): diff --git a/coriolis/tests/osmorphing/test_windows.py b/coriolis/tests/osmorphing/test_windows.py index ce6c7fa58..220eff560 100644 --- a/coriolis/tests/osmorphing/test_windows.py +++ b/coriolis/tests/osmorphing/test_windows.py @@ -168,7 +168,7 @@ def test__add_dism_driver(self, mock_get_worker_os_drive_path): [ '/add-driver', '/image:%s' % self.os_root_dir, - '/driver:%s' % mock.sentinel.driver_path, + '/driver:"%s"' % mock.sentinel.driver_path, '/recurse', '/forceunsigned', ], @@ -209,7 +209,7 @@ def test__mount_disk_image(self): result = self.morphing_tools._mount_disk_image(mock.sentinel.path) self.conn.exec_ps_command.assert_called_once_with( - '"$((Mount-DiskImage \'%s\' -PassThru | Get-Volume).DriveLetter)"' + "(Mount-DiskImage '%s' -PassThru | Get-Volume).DriveLetter" % mock.sentinel.path, include_stderr=True, ) @@ -251,7 +251,6 @@ def test__expand_archive_remove_destination(self): [ mock.call("rm -recurse -force %s" % destination), mock.call( - "$ProgressPreference = 'SilentlyContinue'; " "Expand-Archive -LiteralPath '%(path)s' " "-DestinationPath '%(destination)s' -Force" % {"path": mock.sentinel.archive_path, "destination": destination}, @@ -1442,9 +1441,8 @@ def test_setup_qemu_agent_installation_local_script_from_path( exp_msi_dest_path = "C:\\Cloudbase-Init\\qemu-ga.msi" self.morphing_tools._conn.download_file.assert_not_called() self.morphing_tools._conn.exec_ps_command.assert_called_once_with( - "Copy-Item -Force '%s' -Destination '%s'" - % (fake_msi_source_path, exp_msi_dest_path), - ignore_stdout=True, + "Copy-Item '%s' -Destination '%s'" + % (fake_msi_source_path, exp_msi_dest_path) ) exp_script = windows.QEMU_GUEST_AGENT_INSTALL_SCRIPT_FORMAT % { diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index de6a6dd4f..72e5fc4a9 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -366,20 +366,54 @@ def test_poll_instance_missing_auth(self): poll_interval=5, ) - def test_poll_instance_winrm_rejected(self): - connection_info = { - "ip": "1.2.3.4", - "port": 5986, - "username": "Administrator", - "password": "pwned", - } + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) + @mock.patch("time.sleep") + def test_poll_instance_winrm( + self, + mock_sleep, + mock_wsman, + ): + mock_conn = mock.Mock() + mock_conn.exec_ps_command.side_effect = [Exception, mock.sentinel.stdout] + mock_wsman.from_connection_info.return_value = mock_conn + poll_interval = 5 + + connection_info = self._get_mock_conn_info() + provider_utils.poll_instance_until_reachable( + connection_info=connection_info, + protocol=constants.PROTOCOL_WINRM, + timeout=30, + poll_interval=poll_interval, + ) + + mock_wsman.from_connection_info.assert_has_calls( + [mock.call(connection_info)] * 2, any_order=True + ) + mock_conn.exec_ps_command.assert_has_calls([mock.call("whoami")] * 2) + mock_sleep.assert_called_once_with(poll_interval) + + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) + @mock.patch("time.sleep") + @mock.patch("time.time") + def test_poll_instance_winrm_timeout( + self, + mock_time, + mock_sleep, + mock_wsman, + ): + poll_interval = 5 + mock_time.side_effect = [x * 10 for x in range(20)] + mock_conn = mock.Mock() + mock_conn.exec_ps_command.side_effect = IOError + mock_wsman.from_connection_info.return_value = mock_conn + connection_info = self._get_mock_conn_info() self.assertRaises( - exception.InvalidInput, + exception.CoriolisException, provider_utils.poll_instance_until_reachable, connection_info=connection_info, protocol=constants.PROTOCOL_WINRM, timeout=30, - poll_interval=5, + poll_interval=poll_interval, ) def test_poll_instance_unsupported_protocol(self): diff --git a/coriolis/tests/test_windows_conn.py b/coriolis/tests/test_windows_conn.py new file mode 100644 index 000000000..a0856a03d --- /dev/null +++ b/coriolis/tests/test_windows_conn.py @@ -0,0 +1,30 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from unittest import mock + +from coriolis import windows_conn, windows_ssh, wsman +from coriolis.tests import test_base + + +class WindowsConnTestCase(test_base.CoriolisBaseTestCase): + def test_uses_winrm_default_port(self): + self.assertTrue(windows_conn.uses_winrm({"ip": "10.0.0.1"})) + self.assertTrue(windows_conn.uses_winrm({"ip": "10.0.0.1", "port": 5986})) + self.assertFalse(windows_conn.uses_winrm({"ip": "10.0.0.1", "port": 22})) + + @mock.patch.object(wsman.WSManConnection, "from_connection_info") + @mock.patch.object(windows_ssh.WindowsSSHConnection, "from_connection_info") + def test_from_connection_info_winrm(self, mock_ssh, mock_winrm): + conn_info = {"ip": "10.0.0.1", "port": 5986, "username": "u", "password": "p"} + windows_conn.from_connection_info(conn_info, timeout=30) + mock_winrm.assert_called_once_with(conn_info, 30) + mock_ssh.assert_not_called() + + @mock.patch.object(wsman.WSManConnection, "from_connection_info") + @mock.patch.object(windows_ssh.WindowsSSHConnection, "from_connection_info") + def test_from_connection_info_ssh(self, mock_ssh, mock_winrm): + conn_info = {"ip": "10.0.0.1", "port": 22, "username": "u", "password": "p"} + windows_conn.from_connection_info(conn_info, timeout=30) + mock_ssh.assert_called_once_with(conn_info, 30) + mock_winrm.assert_not_called() diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py index 3b66f0d31..233ac8826 100644 --- a/coriolis/tests/test_windows_ssh.py +++ b/coriolis/tests/test_windows_ssh.py @@ -255,42 +255,21 @@ def test_is_reg_exe(self): self.assertTrue(windows_ssh._is_reg_exe("C:\\Windows\\System32\\reg.exe")) self.assertFalse(windows_ssh._is_reg_exe("dism.exe")) - def test_exec_command_releases_ps_handles_for_reg(self): - self.conn._release_ps_registry_handles = mock.Mock() + def test_exec_command_closes_ps_session_for_reg(self): + self.conn._close_ps_session = mock.Mock() stdout = mock.Mock() stdout.channel = _fake_exec_channel() self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) - self.conn._release_ps_registry_handles.assert_called_once_with() + self.conn._close_ps_session.assert_called_once_with(wait=True) - def test_exec_command_skips_handle_release_for_other_cmds(self): - self.conn._release_ps_registry_handles = mock.Mock() + def test_exec_command_keeps_ps_session_for_other_cmds(self): + self.conn._close_ps_session = mock.Mock() stdout = mock.Mock() stdout.channel = _fake_exec_channel() self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) - self.conn._release_ps_registry_handles.assert_not_called() - - def test_release_ps_registry_handles_closes_session(self): - self.conn._close_ps_session = mock.Mock() - self.conn._release_ps_registry_handles() - self.conn._close_ps_session.assert_called_once_with(wait=True) - - def test_escape_trailing_backslash_for_ssh(self): - odd = "dism.exe /get-drivers /image:F:\\" - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh(odd), - "dism.exe /get-drivers /image:F:\\\\", - ) - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), - "reg.exe unload HKLM\\x", - ) - even = "cmd /c dir C:\\\\" - self.assertEqual( - windows_ssh._escape_trailing_backslash_for_ssh(even), - even, - ) + self.conn._close_ps_session.assert_not_called() def test_exec_ps_command(self): self.conn._invoke_persistent_ps = mock.Mock() @@ -343,74 +322,6 @@ def test_write_file(self): ignore_stdout=True, ) - def test_format_windows_command_quotes_spaces(self): - result = windows_ssh._format_windows_command( - "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] - ) - self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') - - def test_format_windows_command_quotes_icacls_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh._format_windows_command( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant "%s"' % grant, - ) - - def test_build_ssh_exec_command_wraps_cmd_and_quotes_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh._build_ssh_exec_command( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'cmd.exe /c "icacls.exe ' - 'F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant ""%s"""' % grant, - ) - - def test_build_ssh_exec_command_dism_image_trailing_backslash(self): - result = windows_ssh._build_ssh_exec_command( - "dism.exe", ["/Get-Drivers", "/image:F:\\"] - ) - self.assertEqual( - result, - 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', - ) - - def test_build_ssh_exec_command_dism_add_driver_no_nested_quotes(self): - driver = "G:\\Balloon\\2k19\\amd64" - result = windows_ssh._build_ssh_exec_command( - "C:\\Windows\\System32\\dism.exe", - [ - "/add-driver", - "/image:F:\\", - "/driver:%s" % driver, - "/recurse", - "/forceunsigned", - ], - ) - self.assertEqual( - result, - 'cmd.exe /c "C:\\Windows\\System32\\dism.exe /add-driver ' - '/image:F:\\ /driver:G:\\Balloon\\2k19\\amd64 /recurse ' - '/forceunsigned"', - ) - self.assertNotIn("/driver:\"", result) - def test_split_on_marker_line(self): buf = b"True\r\nCORIOLIS_PS_DONE_abc:0\r\nleftover" parsed = windows_ssh._split_on_marker_line(buf, "CORIOLIS_PS_DONE_abc") @@ -427,6 +338,7 @@ def test_split_on_marker_line_incomplete(self): def test_strip_ps_output_trims_blank_lines(self): self.assertEqual(windows_ssh._strip_ps_output("\r\nG\r\n"), "G") self.assertEqual(windows_ssh._strip_ps_output("True\r\n"), "True") + self.assertEqual(windows_ssh._strip_ps_output("\n1\n\n2\n"), "1\r\n2") def test_ps_session_is_alive(self): channel = mock.Mock() @@ -487,6 +399,7 @@ def test_build_ps_wrapper_uses_encoded_command(self): wrapper = self.conn._build_ps_wrapper("Test-Path", "abc") self.assertIn("CORIOLIS_PS_DONE_abc", wrapper) self.assertIn("Invoke-Expression", wrapper) + self.assertIn("Out-String", wrapper) self.assertIn("$ProgressPreference = 'SilentlyContinue'", wrapper) self.assertIn("VABlAHMAdAAtAFAAYQB0AGgA", wrapper) diff --git a/coriolis/tests/test_windows_ssh_cmd.py b/coriolis/tests/test_windows_ssh_cmd.py new file mode 100644 index 000000000..fd833396c --- /dev/null +++ b/coriolis/tests/test_windows_ssh_cmd.py @@ -0,0 +1,104 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from coriolis import windows_ssh_cmd +from coriolis.tests import test_base + + +class WinrmToSshCmdTestCase(test_base.CoriolisBaseTestCase): + def test_unwrap_winrm_driver_quotes(self): + self.assertEqual( + windows_ssh_cmd.unwrap_winrm_arg_quotes( + '/driver:"G:\\Balloon\\2k19\\amd64"' + ), + "/driver:G:\\Balloon\\2k19\\amd64", + ) + + def test_winrm_exec_to_ssh_dism_quotes_match_plain_path(self): + driver = "G:\\Balloon\\2k19\\amd64" + quoted = windows_ssh_cmd.winrm_exec_to_ssh( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + '/driver:"%s"' % driver, + "/recurse", + "/forceunsigned", + ], + ) + plain = windows_ssh_cmd.winrm_exec_to_ssh( + "C:\\Windows\\System32\\dism.exe", + [ + "/add-driver", + "/image:F:\\", + "/driver:%s" % driver, + "/recurse", + "/forceunsigned", + ], + ) + self.assertEqual(quoted, plain) + self.assertNotIn('/driver:"', quoted) + + def test_winrm_exec_to_ssh_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh_cmd.winrm_exec_to_ssh( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'cmd.exe /c "icacls.exe ' + 'F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant ""%s"""' % grant, + ) + + def test_format_windows_command_quotes_spaces(self): + result = windows_ssh_cmd.format_windows_command( + "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] + ) + self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') + + def test_format_windows_command_quotes_icacls_grant(self): + grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" + result = windows_ssh_cmd.format_windows_command( + "icacls.exe", + [ + "F:\\Windows\\System32\\DriverStore\\FileRepository", + "/grant", + grant, + ], + ) + self.assertEqual( + result, + 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' + '/grant "%s"' % grant, + ) + + def test_escape_trailing_backslash_for_ssh(self): + odd = "dism.exe /get-drivers /image:F:\\" + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh(odd), + "dism.exe /get-drivers /image:F:\\\\", + ) + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), + "reg.exe unload HKLM\\x", + ) + even = "cmd /c dir C:\\\\" + self.assertEqual( + windows_ssh_cmd.escape_trailing_backslash_for_ssh(even), + even, + ) + + def test_winrm_exec_to_ssh_dism_image_trailing_backslash(self): + result = windows_ssh_cmd.winrm_exec_to_ssh( + "dism.exe", ["/Get-Drivers", "/image:F:\\"] + ) + self.assertEqual( + result, + 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', + ) diff --git a/coriolis/tests/test_wsman.py b/coriolis/tests/test_wsman.py index 6cbb5c410..93206e6b2 100644 --- a/coriolis/tests/test_wsman.py +++ b/coriolis/tests/test_wsman.py @@ -1,17 +1,239 @@ -# Copyright 2026 Cloudbase Solutions Srl +# Copyright 2023 Cloudbase Solutions Srl # All Rights Reserved. +import logging +from unittest import mock + +import requests +from winrm import protocol + from coriolis import exception, wsman from coriolis.tests import test_base -class WSManShimTestCase(test_base.CoriolisBaseTestCase): - def test_from_connection_info_raises(self): +class WSManConnectionTestCase(test_base.CoriolisBaseTestCase): + """Test suite for the Coriolis WSManConnection class.""" + + def setUp(self): + super(WSManConnectionTestCase, self).setUp() + self.conn = wsman.WSManConnection() + self.conn._protocol = mock.Mock() + self.conn._conn_timeout = 10 + self.cmd = "test_cmd" + self.args = ["-RecoveryPassword", "'ShouldNotBeLogged'"] + self.sanitized_cmd = "test_cmd -RecoveryPassword '***'" + self.url = "http://example.com/file" + self.remote_path = "/remote/path" + + def test__init__timeout(self): + self.connection = wsman.WSManConnection() + self.assertEqual(self.connection._conn_timeout, wsman.DEFAULT_TIMEOUT) + + def test__init__timeout_set(self): + self.connection = wsman.WSManConnection(timeout=100) + self.assertEqual(self.connection._conn_timeout, 100) + + @mock.patch.object(protocol, 'Protocol') + def test_connect(self, mock_protocol): + self.conn.connect('url', 'username', cert_pem='test_cert') + mock_protocol.assert_called_once_with( + endpoint='url', + transport='ssl', + username='username', + password=None, + cert_pem="test_cert", + cert_key_pem=None, + ) + + @mock.patch.object(protocol, 'Protocol') + def test_connect_no_auth(self, mock_protocol): + self.conn.connect('url', 'username') + mock_protocol.assert_called_once_with( + endpoint='url', + transport='plaintext', + username='username', + password=None, + cert_pem=None, + cert_key_pem=None, + ) + + @mock.patch.object(wsman.WSManConnection, 'connect') + @mock.patch('coriolis.utils.wait_for_port_connectivity') + def test_from_connection_info(self, mock_wait_for_port_connectivity, mock_connect): + connection_info = { + "ip": "127.0.0.1", + "username": "user", + "password": "pass", + } + result = self.conn.from_connection_info(connection_info) + mock_wait_for_port_connectivity.assert_called_once_with("127.0.0.1", 5986) + mock_connect.assert_called_once_with( + url="https://127.0.0.1:5986/wsman", + username="user", + password="pass", + cert_pem=None, + cert_key_pem=None, + ) + self.assertIsInstance(result, self.conn.__class__) + + def test_from_connection_info_missing_keys(self): + self.assertRaises( + ValueError, + self.conn.from_connection_info, + {"username": "user", "password": "pass"}, + ) + + def test_from_connection_info_invalid_type(self): + self.assertRaises( + ValueError, self.conn.from_connection_info, 'invalid-connection-type' + ) + + def test_disconnect(self): + self.conn.disconnect() + self.assertIsNone(self.conn._protocol) + + def test_set_timeout(self): + self.conn.set_timeout(self.conn._conn_timeout) + self.assertEqual(self.conn._protocol.transport.timeout, self.conn._conn_timeout) + self.assertEqual(self.conn._protocol.timeout, self.conn._conn_timeout) + + def test__exec_command(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) + std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertEqual(std_err, "std_err") + self.assertEqual(exit_code, 0) + + self.conn._protocol.open_shell.assert_called_once_with( + codepage=wsman.CODEPAGE_UTF8 + ) + shell_id = self.conn._protocol.open_shell.return_value + self.conn._protocol.run_command.assert_called_once_with( + shell_id, self.cmd, self.args + ) + command_id = self.conn._protocol.run_command.return_value + self.conn._protocol.get_command_output.assert_called_once_with( + shell_id, command_id + ) + self.conn._protocol.cleanup_command.assert_called_once_with( + shell_id, command_id + ) + self.conn._protocol.close_shell.assert_called_once_with(shell_id) + + def test__exec_command_exception(self): + self.conn._protocol.get_command_output.side_effect = ( + requests.exceptions.ReadTimeout + ) + self.assertRaises( + exception.OSMorphingWinRMOperationTimeout, + self.conn._exec_command, + self.cmd, + self.args, + ) + self.conn._protocol.cleanup_command.assert_called_once_with(mock.ANY, mock.ANY) + self.conn._protocol.close_shell.assert_called_once_with(mock.ANY) + + @mock.patch("time.sleep") + def test__exec_command_invalid_credentials(self, mock_sleep): + self.conn._protocol.open_shell.side_effect = ( + wsman.winrm_exceptions.InvalidCredentialsError + ) + self.assertRaises( - exception.InvalidInput, - wsman.WSManConnection.from_connection_info, - {"ip": "10.0.0.1", "username": "admin", "password": "x"}, + exception.NotAuthorized, self.conn._exec_command, self.cmd, self.args ) + self.conn._protocol.close_shell.assert_not_called() - def test_constructor_raises(self): - self.assertRaises(exception.InvalidInput, wsman.WSManConnection) + def test_exec_command(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 0) + exp_sanitized_log = ( + "DEBUG:coriolis.wsman:Executing WSMAN command: %s" % self.sanitized_cmd + ) + with self.assertLogs("coriolis.wsman", level=logging.DEBUG) as log_cm: + std_out = self.conn.exec_command(self.cmd, self.args) + self.assertEqual(std_out, "std_out") + self.assertIn(exp_sanitized_log, log_cm.output) + + def test_exec_command_exception(self): + self.conn._protocol.get_command_output.return_value = ("std_out", "std_err", 1) + self.assertRaises( + exception.CoriolisException, self.conn.exec_command, self.cmd, self.args + ) + + def test_exec_ps_command(self): + self.conn.exec_command = mock.Mock() + self.conn.exec_command.return_value = "std_out\n\n" + result = self.conn.exec_ps_command( + self.cmd, + include_stderr=False, + ) + self.conn.exec_command.assert_called_once_with( + "powershell.exe", + [ + "-EncodedCommand", + 'dABlAHMAdABfAGMAbQBkAA==', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + ], + timeout=None, + sanitizable=False, + include_stderr=False, + ) + self.assertEqual(result, "std_out") + + def test_exec_ps_command_with_stderr(self): + self.conn.exec_command = mock.Mock() + self.conn.exec_command.return_value = "std_out\n\n", "stderr" + result = self.conn.exec_ps_command( + self.cmd, + include_stderr=True, + ) + self.conn.exec_command.assert_called_once_with( + "powershell.exe", + [ + "-EncodedCommand", + 'dABlAHMAdABfAGMAbQBkAA==', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + ], + timeout=None, + sanitizable=False, + include_stderr=True, + ) + self.assertEqual(result, ("std_out", "stderr")) + + def test_test_path(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.return_value = "True" + result = self.conn.test_path("test_path") + self.conn.exec_ps_command.assert_called_once_with( + "Test-Path -Path \"test_path\"" + ) + self.assertTrue(result) + + def test_download_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.download_file(self.url, self.remote_path) + self.conn.exec_ps_command.assert_called_once() + + def test_download_file_exception(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.exec_ps_command.side_effect = exception.CoriolisException + self.assertRaises( + exception.CoriolisException, + self.conn.download_file, + self.url, + self.remote_path, + ) + self.conn.exec_ps_command.assert_called_once() + + def test_write_file(self): + self.conn.exec_ps_command = mock.Mock() + self.conn.write_file(self.remote_path, b'file content') + self.conn.exec_ps_command.assert_called_once_with( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (self.remote_path, 'ZmlsZSBjb250ZW50'), + ignore_stdout=True, + ) diff --git a/coriolis/windows_conn.py b/coriolis/windows_conn.py new file mode 100644 index 000000000..568ad7fdf --- /dev/null +++ b/coriolis/windows_conn.py @@ -0,0 +1,30 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Pick a Windows minion connection from connection_info port. + +Port 5986 uses WinRM. Any other port, including 22, uses SSH. +A missing port uses WinRM, which matches the old os-mount default. +""" + +from coriolis import windows_ssh, wsman + +WINRM_HTTPS_PORT = 5986 + + +def uses_winrm(connection_info): + port = (connection_info or {}).get("port") + if port is None or port == "": + return True + return int(port) == WINRM_HTTPS_PORT + + +def from_connection_info(connection_info, timeout=None): + """Return WSManConnection or WindowsSSHConnection.""" + if uses_winrm(connection_info): + conn_cls = wsman.WSManConnection + else: + conn_cls = windows_ssh.WindowsSSHConnection + if timeout is None: + return conn_cls.from_connection_info(connection_info) + return conn_cls.from_connection_info(connection_info, timeout) diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py index 865c02b2d..0c247d0f6 100644 --- a/coriolis/windows_ssh.py +++ b/coriolis/windows_ssh.py @@ -3,9 +3,11 @@ """SSH connection for Windows OS morphing minions. -The Coriolis worker connects to the minion over OpenSSH. It does not open -WinRM. Morphing commands still run as powershell.exe, diskpart, reg.exe, -and DISM. +The Coriolis worker connects to the minion over OpenSSH when +connection_info port is not 5986. Port 5986 still uses WinRM. + +Morphing commands stay in WinRM argv form. Native commands are converted +for SSH in coriolis.windows_ssh_cmd. Minion requirements ------------------- @@ -24,9 +26,9 @@ present on Windows Server. * The Coriolis worker must reach the minion IP on the SSH port. -Not required ------------- -* WinRM, HTTPS port 5986, or a WinRM listener. +Not required for the SSH path +----------------------------- +* WinRM, HTTPS port 5986, or a WinRM listener (used only when port is 5986). * PowerShell 7, pwsh.exe, or an OpenSSH Subsystem powershell line. * PSRP remoting (Enter-PSSession -HostName, pypsrp SSH). """ @@ -40,7 +42,7 @@ from oslo_log import log as logging from oslo_utils import strutils -from coriolis import exception, utils +from coriolis import exception, utils, windows_ssh_cmd LOG = logging.getLogger(__name__) @@ -70,51 +72,14 @@ def _is_reg_exe(cmd): return base in ("reg", "reg.exe") -_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") - - -def _quote_cmd_arg(part): - part_str = str(part) - if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): - return '"%s"' % part_str.replace('"', '""') - return part_str - - -def _format_windows_command(cmd, args): - return " ".join(_quote_cmd_arg(p) for p in [cmd] + list(args or [])) - - -def _escape_trailing_backslash_for_ssh(command): - """Keep a trailing backslash from eating the SSH quote. - - Windows OpenSSH wraps the exec string in double quotes. An odd number - of trailing backslashes escapes that quote. PowerShell then reports a - missing string terminator. DISM /image:F:\\ is the usual case. - """ - n = len(command) - len(command.rstrip("\\")) - if n % 2 == 1: - return command + "\\" - return command - - -def _wrap_native_command_for_ssh(command): - """Run native tools via cmd.exe. - - OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in - icacls grants as a command. cmd.exe does not when the grant is quoted. - """ - command = _escape_trailing_backslash_for_ssh(command) - return 'cmd.exe /c "%s"' % command.replace('"', '""') - - -def _build_ssh_exec_command(cmd, args): - formatted = _format_windows_command(cmd, args) - wrapped = _wrap_native_command_for_ssh(formatted) - return _escape_trailing_backslash_for_ssh(wrapped) - - def _strip_ps_output(stdout): - return (stdout or "").strip() + """Drop blank lines that persistent PowerShell Out-Default inserts.""" + lines = [ + line.strip() + for line in (stdout or "").replace("\r\n", "\n").split("\n") + if line.strip() + ] + return "\r\n".join(lines) def _drain_ssh_channel(channel, stdout_buf, stderr_buf): @@ -438,15 +403,6 @@ def _ensure_ps_session(self): LOG.warning("PowerShell SSH session is not alive. Starting a new session.") self._restart_ps_session() - def _release_ps_registry_handles(self): - """Stop PowerShell so it does not hold loaded hive keys. - - Get-ItemProperty keeps RegistryKey objects in this process. - Garbage collection does not drop those handles. End the process - before reg.exe load or unload. - """ - self._close_ps_session(wait=True) - def _build_ps_wrapper(self, cmd, token): encoded_cmd = base64.b64encode(cmd.encode("utf-16le")).decode() marker = "CORIOLIS_PS_DONE_%s" % token @@ -455,7 +411,12 @@ def _build_ps_wrapper(self, cmd, token): "$__c = [System.Text.Encoding]::Unicode.GetString(" "[Convert]::FromBase64String('%s')); " "$__e = 0; " - "try { Invoke-Expression -Command $__c } " + "try { " + "Invoke-Expression -Command $__c | " + "Out-String -Width 4096 -Stream | " + "Where-Object { $_.Trim() -ne '' } | " + "ForEach-Object { Write-Output $_.Trim() } " + "} " "catch { Write-Error -ErrorRecord $_; $__e = 1 }; " "Write-Output ('%s:' + $__e)\r\n" % (encoded_cmd, marker) ) @@ -533,7 +494,7 @@ def _read_ssh_exec_output(self, channel, timeout, sanitized_cmd): ] ) def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): - command = _build_ssh_exec_command(cmd, args) + command = windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) if sanitizable: sanitized_cmd = strutils.mask_password(command) else: @@ -558,11 +519,15 @@ def exec_command( include_stderr=False, ): if sanitizable: - sanitized_cmd = strutils.mask_password(_build_ssh_exec_command(cmd, args)) + sanitized_cmd = strutils.mask_password( + windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) + ) else: sanitized_cmd = "***" if _is_reg_exe(cmd): - self._release_ps_registry_handles() + # Get-ItemProperty keeps hive handles in this powershell.exe. + # Close that process before reg.exe load or unload. + self._close_ps_session(wait=True) LOG.debug("Executing Windows SSH command: %s", sanitized_cmd) std_out, std_err, exit_code = self._exec_command( cmd, args, timeout=timeout, sanitizable=sanitizable diff --git a/coriolis/windows_ssh_cmd.py b/coriolis/windows_ssh_cmd.py new file mode 100644 index 000000000..5647303f8 --- /dev/null +++ b/coriolis/windows_ssh_cmd.py @@ -0,0 +1,72 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Convert WinRM native (cmd, args) pairs to an OpenSSH command line. + +Osmorphing builds native tool argv for WinRM. WinRM runs argv as-is. +SSH sends one string. OpenSSH DefaultShell is often PowerShell, so the +converter wraps cmd.exe /c and quotes for cmd.exe. + +WinRM arguments may contain quotes that were only argv delimiters, such +as /driver:"G:\\path". Those quotes must not stay in the DISM path. +""" + +_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") + + +def unwrap_winrm_arg_quotes(part): + """Remove WinRM argv quotes from one native argument.""" + text = str(part).strip() + changed = True + while changed and text: + changed = False + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + text = text[1:-1] + changed = True + continue + idx = text.find(':"') + if idx >= 0 and text.endswith('"'): + text = text[: idx + 1] + text[idx + 2 : -1] + changed = True + return text + + +def quote_cmd_arg(part): + part_str = unwrap_winrm_arg_quotes(part) + if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): + return '"%s"' % part_str.replace('"', '""') + return part_str + + +def format_windows_command(cmd, args): + return " ".join(quote_cmd_arg(p) for p in [cmd] + list(args or [])) + + +def escape_trailing_backslash_for_ssh(command): + """Keep a trailing backslash from eating the SSH quote. + + Windows OpenSSH wraps the exec string in double quotes. An odd number + of trailing backslashes escapes that quote. PowerShell then reports a + missing string terminator. DISM /image:F:\\ is the usual case. + """ + n = len(command) - len(command.rstrip("\\")) + if n % 2 == 1: + return command + "\\" + return command + + +def wrap_native_command_for_ssh(command): + """Run native tools via cmd.exe. + + OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in + icacls grants as a command. cmd.exe does not when the grant is quoted. + """ + command = escape_trailing_backslash_for_ssh(command) + return 'cmd.exe /c "%s"' % command.replace('"', '""') + + +def winrm_exec_to_ssh(cmd, args=None): + """Return the SSH exec string for a WinRM (cmd, args) pair.""" + formatted = format_windows_command(cmd, args) + wrapped = wrap_native_command_for_ssh(formatted) + return escape_trailing_backslash_for_ssh(wrapped) diff --git a/coriolis/wsman.py b/coriolis/wsman.py index 5fdab5b7e..27dd661a1 100644 --- a/coriolis/wsman.py +++ b/coriolis/wsman.py @@ -1,23 +1,248 @@ -# Copyright 2026 Cloudbase Solutions Srl +# Copyright 2016 Cloudbase Solutions Srl # All Rights Reserved. -"""Import shim for leftover providers that still import coriolis.wsman. +import base64 -Windows morphing uses SSH. This module does not open WinRM. -Remove this module after the leftover providers stop importing it. -""" +import requests +from oslo_log import log as logging +from oslo_utils import strutils +from winrm import exceptions as winrm_exceptions +from winrm import protocol -from coriolis import exception +from coriolis import exception, utils -_WINRM_REMOVED_MSG = "Windows minion connections must use SSH. WinRM is not supported." +AUTH_BASIC = "basic" +AUTH_KERBEROS = "kerberos" +AUTH_CERTIFICATE = "certificate" +CODEPAGE_UTF8 = 65001 +DEFAULT_TIMEOUT = 3600 + +LOG = logging.getLogger(__name__) -class WSManConnection(object): - """Raise on use. Leftover providers import this class at load time.""" +class WSManConnection(object): def __init__(self, timeout=None): - raise exception.InvalidInput(_WINRM_REMOVED_MSG) + self._protocol = None + self._conn_timeout = int(timeout or DEFAULT_TIMEOUT) + + EOL = "\r\n" + + @utils.retry_on_error() + def connect( + self, url, username, auth=None, password=None, cert_pem=None, cert_key_pem=None + ): + if not auth: + if cert_pem: + auth = AUTH_CERTIFICATE + else: + auth = AUTH_BASIC + + auth_transport_map = { + AUTH_BASIC: 'plaintext', + AUTH_KERBEROS: 'kerberos', + AUTH_CERTIFICATE: 'ssl', + } + + self._protocol = protocol.Protocol( + endpoint=url, + transport=auth_transport_map[auth], + username=username, + password=password, + cert_pem=cert_pem, + cert_key_pem=cert_key_pem, + ) @classmethod - def from_connection_info(cls, connection_info, timeout=None): - raise exception.InvalidInput(_WINRM_REMOVED_MSG) + def from_connection_info(cls, connection_info, timeout=DEFAULT_TIMEOUT): + """Returns a wsman.WSManConnection obj for the provided conn info.""" + if not isinstance(connection_info, dict): + raise ValueError( + "WSMan connection must be a dict. Got type '%s', value: %s" + % (type(connection_info), connection_info) + ) + + required_keys = ["ip", "username", "password"] + missing = [key for key in required_keys if key not in connection_info] + if missing: + raise ValueError( + "The following keys were missing from WSMan connection " + "info %s. Got: %s" % (missing, connection_info) + ) + + host = connection_info["ip"] + port = connection_info.get("port", 5986) + username = connection_info["username"] + password = connection_info.get("password") + cert_pem = connection_info.get("cert_pem") + cert_key_pem = connection_info.get("cert_key_pem") + url = "https://%s:%s/wsman" % (host, port) + + LOG.info( + "Waiting for connectivity on host: %(host)s:%(port)s", + {"host": host, "port": port}, + ) + utils.wait_for_port_connectivity(host, port) + + conn = cls(timeout) + conn.connect( + url=url, + username=username, + password=password, + cert_pem=cert_pem, + cert_key_pem=cert_key_pem, + ) + + return conn + + def disconnect(self): + self._protocol = None + + def set_timeout(self, timeout): + if timeout: + self._protocol.timeout = timeout + self._protocol.transport.timeout = timeout + + @utils.retry_on_error( + terminal_exceptions=[ + winrm_exceptions.InvalidCredentialsError, + exception.OSMorphingWinRMOperationTimeout, + ] + ) + def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): + if sanitizable: + sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) + else: + sanitized_cmd = "***" + + timeout = int(timeout or self._conn_timeout) + self.set_timeout(timeout) + shell_id = None + try: + shell_id = self._protocol.open_shell(codepage=CODEPAGE_UTF8) + command_id = self._protocol.run_command(shell_id, cmd, args) + try: + (std_out, std_err, exit_code) = self._protocol.get_command_output( + shell_id, command_id + ) + except requests.exceptions.ReadTimeout: + raise exception.OSMorphingWinRMOperationTimeout( + cmd=sanitized_cmd, timeout=timeout + ) + finally: + self._protocol.cleanup_command(shell_id, command_id) + + return (std_out, std_err, exit_code) + except winrm_exceptions.InvalidCredentialsError as ex: + raise exception.NotAuthorized( + message="The WinRM connection credentials are invalid. " + "If you are using a template with a default " + "pre-baked username/password, please ensure " + "that you have passed the credentials to the " + "destination Coriolis plugin you have selected," + " either via the Target Environment parameters " + "set when creating the Migration/Replica, or " + "by setting it in the destination plugin's " + "dedicated section of the coriolis.conf " + "static configuration file." + ) from ex + finally: + if shell_id: + self._protocol.close_shell(shell_id) + + def exec_command( + self, + cmd, + args=[], + timeout=None, + sanitizable=True, + include_stderr=False, + ): + # Our sanitization helpers do not work for base64 encoded commands, + # in which case we'll avoid logging it so that we won't leak + # sensitive information. + if sanitizable: + sanitized_cmd = strutils.mask_password("%s %s" % (cmd, " ".join(args))) + else: + sanitized_cmd = "***" + LOG.debug("Executing WSMAN command: %s", sanitized_cmd) + std_out, std_err, exit_code = self._exec_command( + cmd, args, timeout=timeout, sanitizable=sanitizable + ) + + if exit_code: + raise exception.CoriolisException( + "Command \"%s\" failed with exit code: %s\n" + "stdout: %s\nstd_err: %s" % (sanitized_cmd, exit_code, std_out, std_err) + ) + + if include_stderr: + return std_out, std_err + return std_out + + def exec_ps_command( + self, + cmd, + ignore_stdout=False, + timeout=None, + include_stderr=False, + ): + LOG.debug("Executing PS command: %s", strutils.mask_password(cmd)) + base64_cmd = base64.b64encode(cmd.encode('utf-16le')).decode() + ret = self.exec_command( + "powershell.exe", + [ + "-EncodedCommand", + base64_cmd, + "-NonInteractive", + "-ExecutionPolicy", + "RemoteSigned", + ], + timeout=timeout, + sanitizable=False, + include_stderr=include_stderr, + ) + if include_stderr: + stdout, stderr = ret + return stdout[:-2], stderr + else: + stdout = ret + return stdout[:-2] + + def test_path(self, remote_path): + ret_val = self.exec_ps_command("Test-Path -Path \"%s\"" % remote_path) + return ret_val == "True" + + def download_file(self, url, remote_path): + LOG.debug( + "Downloading: \"%(url)s\" to \"%(path)s\"", + {"url": url, "path": remote_path}, + ) + try: + # Nano Server does not have Invoke-WebRequest and additionally + # this is also faster + self.exec_ps_command( + "[Net.ServicePointManager]::SecurityProtocol = " + "[Net.SecurityProtocolType]::Tls12;" + "if(!([System.Management.Automation.PSTypeName]'" + "System.Net.Http.HttpClient').Type) {$assembly = " + "[System.Reflection.Assembly]::LoadWithPartialName(" + "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." + "GetStreamAsync('%(url)s').Result.CopyTo(" + "(New-Object IO.FileStream '%(outfile)s', Create, Write, " + "None), 1MB)" % {"url": url, "outfile": remote_path}, + ignore_stdout=True, + ) + except exception.CoriolisException as ex: + LOG.trace(utils.get_exception_details()) + raise exception.CoriolisException( + "Failed to download file from URL: %s to path: %s. Please " + "check logs for more details." % (url, remote_path) + ) from ex + + def write_file(self, remote_path, content): + self.exec_ps_command( + "[IO.File]::WriteAllBytes('%s', [Convert]::FromBase64String('%s'))" + % (remote_path, base64.b64encode(content).decode()), + ignore_stdout=True, + ) diff --git a/requirements.txt b/requirements.txt index 89af2d979..ad91a41a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ oslo.service>=1.12.0 oslo.versionedobjects oslo.reports paramiko>=2.1.0 +git+https://github.com/cloudbase/pywinrm.git@requests#egg=pywinrm paste pbr psutil From db9b2e10faa5408d8c4ebefb7e4d3f2a9093fb70 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Wed, 30 Sep 2026 22:58:37 +0300 Subject: [PATCH 3/6] Add Windows minion OpenSSH setup --- coriolis/tests/test_windows_minion.py | 53 +++++++++ coriolis/windows_minion.py | 158 ++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 coriolis/tests/test_windows_minion.py create mode 100644 coriolis/windows_minion.py diff --git a/coriolis/tests/test_windows_minion.py b/coriolis/tests/test_windows_minion.py new file mode 100644 index 000000000..b5f0e7096 --- /dev/null +++ b/coriolis/tests/test_windows_minion.py @@ -0,0 +1,53 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +import base64 + +from coriolis import exception, windows_minion +from coriolis.tests import test_base + + +class WindowsMinionTestCase(test_base.CoriolisBaseTestCase): + def test_script_opens_ssh_on_every_profile(self): + script = windows_minion.build_windows_openssh_script("ssh-rsa AAAAB3 key") + + self.assertNotIn("#ps1_sysnative", script) + self.assertNotIn("net.exe user", script) + self.assertIn("Set-NetFirewallRule", script) + self.assertIn("-Profile Any", script) + self.assertIn("-EdgeTraversalPolicy Allow", script) + self.assertIn("ssh-rsa AAAAB3 key", script) + self.assertNotIn("__CORIOLIS_PUBLIC_KEY__", script) + self.assertNotIn("__CORIOLIS_ACCOUNT_LINE__", script) + + def test_cloudbase_init_script_sets_password(self): + script = windows_minion.build_windows_openssh_script( + "ssh-rsa AAAAB3 key", + username="Administrator", + password="generated-pass", + cloudbase_init=True, + ) + + self.assertTrue(script.startswith("#ps1_sysnative\n")) + self.assertIn("net.exe user Administrator generated-pass", script) + self.assertIn("ssh-rsa AAAAB3 key", script) + + def test_script_rejects_partial_account(self): + self.assertRaises( + exception.InvalidInput, + windows_minion.build_windows_openssh_script, + "ssh-rsa AAAAB3 key", + username="Administrator", + ) + + def test_agent_command_decodes_to_script(self): + command = windows_minion.windows_openssh_agent_command("ssh-rsa AAAAB3 key") + + prefix = "powershell.exe -NoProfile -NonInteractive -EncodedCommand " + self.assertTrue(command.startswith(prefix)) + encoded = command[len(prefix) :] + script = base64.b64decode(encoded).decode("utf-16le") + self.assertEqual( + windows_minion.build_windows_openssh_script("ssh-rsa AAAAB3 key"), + script, + ) diff --git a/coriolis/windows_minion.py b/coriolis/windows_minion.py new file mode 100644 index 000000000..a64ba4d04 --- /dev/null +++ b/coriolis/windows_minion.py @@ -0,0 +1,158 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Shared Windows minion OpenSSH setup. + +The stock OpenSSH firewall rule is Private and blocks edge traversal. +An unidentified NIC does not use the Private profile. +A NAT address needs edge traversal Allow. +The rule must use every profile. + +This module only returns the guest script. +The provider must run the script. +The provider must open TCP 22 on the platform firewall. +Core uses SSH when connection_info port is not 5986. +Port 5986, or a missing port, still uses WinRM. + +Providers that call this module +------------------------------- +OpenStack. +Nova user data. +cloudbase-init runs the script because it starts with #ps1_sysnative. +The image needs UserDataPlugin. + +CloudStack. +deployVirtualMachine user data, base64-encoded by the provider. +Same #ps1_sysnative path as OpenStack. +The script also sets the Administrator password with net user. +The provider opens TCP 22 on the minion public IP. + +Proxmox. +QEMU guest agent. +windows_openssh_agent_command() sends powershell.exe -EncodedCommand. +The script has no #ps1_sysnative header. +The template needs the guest agent. +powershell.exe must be on the agent PATH. +The provider sets the password with a separate net user command. + +Providers that can call this module +------------------------------------ +LXD. +The worker already sets cloud-init user data. +The current text is #cloud-config with ssh_authorized_keys. +That text does not start sshd. +That text does not change the firewall profile. +Replace that text with this script. + +libvirt. +The minion config drive already carries user data. +The current text is #cloud-config. +The connection port is 5986. +Send this script and set the port to 22. + +STACKIT. +Cloud-init user data already runs a PowerShell script. +That script configures a WinRM listener. +The connection port is 5986. +Run this script instead, and set the port to 22. + +AWS. +EC2 runs user data that starts with . +It does not run a #ps1_sysnative script. +Use the script body with an header. +The current Windows user data opens WinRM on port 5986. + +oVirt. +Use this script only when the Windows template runs cloudbase-init. +The provider already allows that initialization path. +windows_openssh_agent_command() does not apply. +The oVirt guest agent reports the IP. +It does not run a QEMU guest-exec command. +A template with only a preset WinRM password has no channel for this script. + +VMware vSphere. +OS morphing clones the Windows template from migr_template_map. +The connection is TCP port 22. +The username and password come from the template maps. +The clone spec does not set user data. +The clone spec does not run a guest command. +The template must already accept that login on port 22. +VMware Tools can run the script with StartProgramInGuest. +The provider does not call that API today. +The backup writer still accepts only a Linux minion. +That check is for disk copy, not for the OS morphing minion. +""" + +import base64 + +from coriolis import exception + +# Placeholders are replaced by build_windows_openssh_script. +_OPENSSH_SCRIPT = """$ErrorActionPreference = 'Stop' +__CORIOLIS_ACCOUNT_LINE__ +$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue +if (-not $svc) { + $cap = Get-WindowsCapability -Online | + Where-Object { $_.Name -like 'OpenSSH.Server*' } | + Select-Object -First 1 + if (-not $cap) { throw 'OpenSSH Server capability is not available' } + Add-WindowsCapability -Online -Name $cap.Name | Out-Null +} +Set-Service -Name sshd -StartupType Automatic +Start-Service -Name sshd +$ruleName = 'OpenSSH-Server-In-TCP' +$rule = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue +if (-not $rule) { + New-NetFirewallRule -Name $ruleName ` + -DisplayName 'OpenSSH Server (sshd)' -Enabled True ` + -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | + Out-Null +} +Set-NetFirewallRule -Name $ruleName -Enabled True -Profile Any ` + -EdgeTraversalPolicy Allow +$keyDir = Join-Path $env:ProgramData 'ssh' +$keyFile = Join-Path $keyDir 'administrators_authorized_keys' +New-Item -ItemType Directory -Force -Path $keyDir | Out-Null +$key = @' +__CORIOLIS_PUBLIC_KEY__ +'@ +$existing = '' +if (Test-Path $keyFile) { $existing = Get-Content -Raw -Path $keyFile } +if ($existing -notlike ('*' + $key + '*')) { + Add-Content -Path $keyFile -Value $key +} +icacls.exe $keyFile /inheritance:r /grant 'Administrators:F' /grant 'SYSTEM:F' | + Out-Null +""" + + +def build_windows_openssh_script( + public_key, username=None, password=None, cloudbase_init=False +): + """Return the PowerShell script that starts sshd and opens TCP 22. + + Set both username and password to add a ``net user`` line. + Set cloudbase_init to prefix ``#ps1_sysnative``. + The password must not contain spaces. + """ + if (username is None) != (password is None): + raise exception.InvalidInput( + "Windows OpenSSH script needs both username and password, or neither." + ) + account_line = "" + if username is not None: + account_line = "net.exe user %s %s" % (username, password) + script = _OPENSSH_SCRIPT.replace( + "__CORIOLIS_ACCOUNT_LINE__\n", + (account_line + "\n") if account_line else "", + ).replace("__CORIOLIS_PUBLIC_KEY__", public_key) + if cloudbase_init: + script = "#ps1_sysnative\n" + script + return script + + +def windows_openssh_agent_command(public_key): + """Return a guest-agent command that starts Windows OpenSSH.""" + script = build_windows_openssh_script(public_key) + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return "powershell.exe -NoProfile -NonInteractive -EncodedCommand " + encoded From 180d22dd991463222a0d7a2324bee8f7c8f978f7 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Wed, 30 Sep 2026 23:43:21 +0300 Subject: [PATCH 4/6] Close file after download --- coriolis/tests/test_windows_ssh.py | 6 +++++- coriolis/windows_ssh.py | 24 +++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py index 233ac8826..e33a57907 100644 --- a/coriolis/tests/test_windows_ssh.py +++ b/coriolis/tests/test_windows_ssh.py @@ -301,7 +301,11 @@ def test_test_path(self): def test_download_file(self): self.conn.exec_ps_command = mock.Mock() self.conn.download_file(self.url, self.remote_path) - self.conn.exec_ps_command.assert_called_once() + cmd = self.conn.exec_ps_command.call_args[0][0] + self.assertIn(self.url, cmd) + self.assertIn(self.remote_path, cmd) + self.assertIn("$outFile.Dispose()", cmd) + self.assertIn("$client.Dispose()", cmd) def test_download_file_exception(self): self.conn.exec_ps_command = mock.Mock() diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py index 0c247d0f6..6ee7e8bef 100644 --- a/coriolis/windows_ssh.py +++ b/coriolis/windows_ssh.py @@ -571,17 +571,27 @@ def download_file(self, url, remote_path): "Downloading: \"%(url)s\" to \"%(path)s\"", {"url": url, "path": remote_path}, ) + # This PowerShell process stays open for later commands. + # Close the zip file here. Expand-Archive must open that path next. try: self.exec_ps_command( "[Net.ServicePointManager]::SecurityProtocol = " - "[Net.SecurityProtocolType]::Tls12;" - "if(!([System.Management.Automation.PSTypeName]'" - "System.Net.Http.HttpClient').Type) {$assembly = " + "[Net.SecurityProtocolType]::Tls12; " + "if (!([System.Management.Automation.PSTypeName]" + "'System.Net.Http.HttpClient').Type) { " "[System.Reflection.Assembly]::LoadWithPartialName(" - "'System.Net.Http')}; (new-object System.Net.Http.HttpClient)." - "GetStreamAsync('%(url)s').Result.CopyTo(" - "(New-Object IO.FileStream '%(outfile)s', Create, Write, " - "None), 1MB)" % {"url": url, "outfile": remote_path}, + "'System.Net.Http') | Out-Null }; " + "$client = New-Object System.Net.Http.HttpClient; " + "$inStream = $null; $outFile = $null; " + "try { " + "$inStream = $client.GetStreamAsync('%(url)s').Result; " + "$outFile = New-Object IO.FileStream '%(outfile)s', " + "Create, Write, None; " + "$inStream.CopyTo($outFile, 1MB) " + "} finally { " + "if ($outFile) { $outFile.Dispose() }; " + "if ($inStream) { $inStream.Dispose() }; " + "$client.Dispose() }" % {"url": url, "outfile": remote_path}, ignore_stdout=True, ) except exception.CoriolisException as ex: From 353631332a4c376caf7b60c7e1c2fe4b45492969 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Thu, 1 Oct 2026 15:39:28 +0300 Subject: [PATCH 5/6] Use native ps commands instead of running with cmd.exe --- coriolis/tests/test_windows_ssh.py | 94 +++++----------------- coriolis/tests/test_windows_ssh_cmd.py | 104 ------------------------- coriolis/windows_ssh.py | 87 +++++++-------------- coriolis/windows_ssh_cmd.py | 72 ----------------- 4 files changed, 45 insertions(+), 312 deletions(-) delete mode 100644 coriolis/tests/test_windows_ssh_cmd.py delete mode 100644 coriolis/windows_ssh_cmd.py diff --git a/coriolis/tests/test_windows_ssh.py b/coriolis/tests/test_windows_ssh.py index e33a57907..ba8c5fd7b 100644 --- a/coriolis/tests/test_windows_ssh.py +++ b/coriolis/tests/test_windows_ssh.py @@ -11,37 +11,6 @@ from coriolis.tests import test_base -def _fake_exec_channel(stdout=b"std_out", stderr=b"std_err", exit_code=0): - """SSH channel that yields stdout/stderr once, then reports exit.""" - out = [stdout] - err = [stderr] - channel = mock.Mock() - - def recv_ready(): - return bool(out[0]) - - def recv_stderr_ready(): - return bool(err[0]) - - def recv(_size): - data = out[0] - out[0] = b"" - return data - - def recv_stderr(_size): - data = err[0] - err[0] = b"" - return data - - channel.recv_ready.side_effect = recv_ready - channel.recv_stderr_ready.side_effect = recv_stderr_ready - channel.recv.side_effect = recv - channel.recv_stderr.side_effect = recv_stderr - channel.exit_status_ready.return_value = True - channel.recv_exit_status.return_value = exit_code - return channel - - class WindowsSSHConnectionTestCase(test_base.CoriolisBaseTestCase): """Test suite for WindowsSSHConnection.""" @@ -194,58 +163,35 @@ def test_connect_invalid_credentials(self, mock_connect_ssh): ) def test__exec_command(self): - stdout = mock.Mock() - stdout.channel = _fake_exec_channel() - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) - + self.conn._invoke_persistent_ps = mock.Mock( + return_value=("std_out", "std_err", 0) + ) std_out, std_err, exit_code = self.conn._exec_command(self.cmd, self.args) - + script = self.conn._invoke_persistent_ps.call_args[0][0] self.assertEqual(std_out, "std_out") self.assertEqual(std_err, "std_err") self.assertEqual(exit_code, 0) - self.conn._ssh.exec_command.assert_called_once_with( - "cmd.exe /c \"test_cmd -RecoveryPassword 'ShouldNotBeLogged'\"", - timeout=float(self.conn._conn_timeout), - ) - - def test__exec_command_reads_stderr_progress_without_stdout(self): - stdout = mock.Mock() - stdout.channel = _fake_exec_channel(stdout=b"", stderr=b"progress" * 100) - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) - - std_out, std_err, exit_code = self.conn._exec_command("dism.exe", []) - - self.assertEqual(std_out, "") - self.assertIn("progress", std_err) - self.assertEqual(exit_code, 0) - - def test__exec_command_timeout(self): - self.conn._ssh.exec_command.side_effect = socket.timeout - self.assertRaises( - exception.OSMorphingSSHOperationTimeout, - self.conn._exec_command, - self.cmd, - self.args, - ) + self.assertIn("& 'test_cmd' '-RecoveryPassword'", script) + self.assertIn("ShouldNotBeLogged", script) + self.assertNotRegex(script, r"(?:^|[;{])\s*exit\b") def test_exec_command(self): - stdout = mock.Mock() - stdout.channel = _fake_exec_channel() - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) - - sanitized_cmd = "cmd.exe /c \"test_cmd -RecoveryPassword '***'\"" - exp_log = "DEBUG:coriolis.windows_ssh:Executing Windows SSH command: %s" % ( - sanitized_cmd + self.conn._invoke_persistent_ps = mock.Mock(return_value=("std_out", "", 0)) + exp_log = ( + "DEBUG:coriolis.windows_ssh:Executing Windows SSH command: " + "test_cmd -RecoveryPassword '***'" ) with self.assertLogs("coriolis.windows_ssh", level=logging.DEBUG) as log_cm: std_out = self.conn.exec_command(self.cmd, self.args) self.assertEqual(std_out, "std_out") self.assertIn(exp_log, log_cm.output) + script = self.conn._invoke_persistent_ps.call_args[0][0] + self.assertIn("& 'test_cmd'", script) def test_exec_command_exception(self): - stdout = mock.Mock() - stdout.channel = _fake_exec_channel(exit_code=1) - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn._invoke_persistent_ps = mock.Mock( + return_value=("std_out", "bad arg", 1) + ) self.assertRaises( exception.CoriolisException, self.conn.exec_command, self.cmd, self.args ) @@ -257,17 +203,13 @@ def test_is_reg_exe(self): def test_exec_command_closes_ps_session_for_reg(self): self.conn._close_ps_session = mock.Mock() - stdout = mock.Mock() - stdout.channel = _fake_exec_channel() - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn._invoke_persistent_ps = mock.Mock(return_value=("", "", 0)) self.conn.exec_command("reg.exe", ["unload", "HKLM\\x"]) self.conn._close_ps_session.assert_called_once_with(wait=True) def test_exec_command_keeps_ps_session_for_other_cmds(self): self.conn._close_ps_session = mock.Mock() - stdout = mock.Mock() - stdout.channel = _fake_exec_channel() - self.conn._ssh.exec_command.return_value = (None, stdout, mock.Mock()) + self.conn._invoke_persistent_ps = mock.Mock(return_value=("", "", 0)) self.conn.exec_command("dism.exe", ["/Get-WimInfo"]) self.conn._close_ps_session.assert_not_called() diff --git a/coriolis/tests/test_windows_ssh_cmd.py b/coriolis/tests/test_windows_ssh_cmd.py deleted file mode 100644 index fd833396c..000000000 --- a/coriolis/tests/test_windows_ssh_cmd.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright 2026 Cloudbase Solutions Srl -# All Rights Reserved. - -from coriolis import windows_ssh_cmd -from coriolis.tests import test_base - - -class WinrmToSshCmdTestCase(test_base.CoriolisBaseTestCase): - def test_unwrap_winrm_driver_quotes(self): - self.assertEqual( - windows_ssh_cmd.unwrap_winrm_arg_quotes( - '/driver:"G:\\Balloon\\2k19\\amd64"' - ), - "/driver:G:\\Balloon\\2k19\\amd64", - ) - - def test_winrm_exec_to_ssh_dism_quotes_match_plain_path(self): - driver = "G:\\Balloon\\2k19\\amd64" - quoted = windows_ssh_cmd.winrm_exec_to_ssh( - "C:\\Windows\\System32\\dism.exe", - [ - "/add-driver", - "/image:F:\\", - '/driver:"%s"' % driver, - "/recurse", - "/forceunsigned", - ], - ) - plain = windows_ssh_cmd.winrm_exec_to_ssh( - "C:\\Windows\\System32\\dism.exe", - [ - "/add-driver", - "/image:F:\\", - "/driver:%s" % driver, - "/recurse", - "/forceunsigned", - ], - ) - self.assertEqual(quoted, plain) - self.assertNotIn('/driver:"', quoted) - - def test_winrm_exec_to_ssh_icacls_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh_cmd.winrm_exec_to_ssh( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'cmd.exe /c "icacls.exe ' - 'F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant ""%s"""' % grant, - ) - - def test_format_windows_command_quotes_spaces(self): - result = windows_ssh_cmd.format_windows_command( - "reg.exe", ["load", "HKLM\\x", "C:\\Program Files\\hive"] - ) - self.assertEqual(result, 'reg.exe load HKLM\\x "C:\\Program Files\\hive"') - - def test_format_windows_command_quotes_icacls_grant(self): - grant = "*S-1-5-21-841993420-4016469602-3165386176-500:(OI)(CI)F" - result = windows_ssh_cmd.format_windows_command( - "icacls.exe", - [ - "F:\\Windows\\System32\\DriverStore\\FileRepository", - "/grant", - grant, - ], - ) - self.assertEqual( - result, - 'icacls.exe F:\\Windows\\System32\\DriverStore\\FileRepository ' - '/grant "%s"' % grant, - ) - - def test_escape_trailing_backslash_for_ssh(self): - odd = "dism.exe /get-drivers /image:F:\\" - self.assertEqual( - windows_ssh_cmd.escape_trailing_backslash_for_ssh(odd), - "dism.exe /get-drivers /image:F:\\\\", - ) - self.assertEqual( - windows_ssh_cmd.escape_trailing_backslash_for_ssh("reg.exe unload HKLM\\x"), - "reg.exe unload HKLM\\x", - ) - even = "cmd /c dir C:\\\\" - self.assertEqual( - windows_ssh_cmd.escape_trailing_backslash_for_ssh(even), - even, - ) - - def test_winrm_exec_to_ssh_dism_image_trailing_backslash(self): - result = windows_ssh_cmd.winrm_exec_to_ssh( - "dism.exe", ["/Get-Drivers", "/image:F:\\"] - ) - self.assertEqual( - result, - 'cmd.exe /c "dism.exe /Get-Drivers /image:F:\\\\"', - ) diff --git a/coriolis/windows_ssh.py b/coriolis/windows_ssh.py index 6ee7e8bef..ad126907f 100644 --- a/coriolis/windows_ssh.py +++ b/coriolis/windows_ssh.py @@ -6,8 +6,8 @@ The Coriolis worker connects to the minion over OpenSSH when connection_info port is not 5986. Port 5986 still uses WinRM. -Morphing commands stay in WinRM argv form. Native commands are converted -for SSH in coriolis.windows_ssh_cmd. +Morphing commands stay in WinRM argv form. SSH runs them in one open +PowerShell process. The process is started with -Command -. Minion requirements ------------------- @@ -21,7 +21,7 @@ * An SSH login that can run elevated morphing commands. Providers usually send Administrator. Auth is a password or an SSH private key. * The SSH user must start powershell.exe with -Command -. The process - reads commands from stdin. PowerShell 7 (pwsh) is not required. + reads later commands from stdin. PowerShell 7 (pwsh) is not required. * diskpart.exe, reg.exe, and DISM must be available. These tools are present on Windows Server. * The Coriolis worker must reach the minion IP on the SSH port. @@ -42,7 +42,7 @@ from oslo_log import log as logging from oslo_utils import strutils -from coriolis import exception, utils, windows_ssh_cmd +from coriolis import exception, utils LOG = logging.getLogger(__name__) @@ -67,6 +67,24 @@ ) +def _ps_single_quote(text): + return "'" + str(text).replace("'", "''") + "'" + + +def _native_ps_script(cmd, args): + """Return a PowerShell script that runs one program. + + The script runs inside the open PowerShell process. It must not call + exit. exit would stop that process. + """ + parts = [_ps_single_quote(cmd)] + parts.extend(_ps_single_quote(part) for part in (args or [])) + return ( + "& %s; if ($null -eq $LASTEXITCODE -or $LASTEXITCODE -ne 0) { " + "throw ('native exit ' + $LASTEXITCODE) }" % " ".join(parts) + ) + + def _is_reg_exe(cmd): base = str(cmd).replace("\\", "/").rsplit("/", 1)[-1].lower() return base in ("reg", "reg.exe") @@ -125,8 +143,7 @@ class WindowsSSHConnection(object): """Windows morphing minion connection over SSH. See the module docstring for minion requirements. PowerShell cmdlets - reuse one remote powershell.exe process. Native commands still use one - SSH exec per call. + and native programs reuse one remote powershell.exe process. """ EOL = "\r\n" @@ -459,56 +476,8 @@ def set_timeout(self, timeout): if timeout: self._conn_timeout = int(timeout) - def _read_ssh_exec_output(self, channel, timeout, sanitized_cmd): - """Read stdout and stderr until the SSH exec channel exits.""" - deadline = time.monotonic() + int(timeout) - stdout_buf = bytearray() - stderr_buf = bytearray() - channel.settimeout(0.1) - while True: - if time.monotonic() >= deadline: - raise exception.OSMorphingSSHOperationTimeout( - cmd=sanitized_cmd, timeout=timeout - ) - try: - _drain_ssh_channel(channel, stdout_buf, stderr_buf) - except socket.timeout: - pass - if channel.exit_status_ready(): - try: - _drain_ssh_channel(channel, stdout_buf, stderr_buf) - except socket.timeout: - pass - if not channel.recv_ready() and not channel.recv_stderr_ready(): - break - time.sleep(0.05) - exit_code = channel.recv_exit_status() - stdout_str = stdout_buf.decode("utf-8", errors="replace") - stderr_str = stderr_buf.decode("utf-8", errors="replace") - return stdout_str, stderr_str, exit_code - - @utils.retry_on_error( - terminal_exceptions=[ - exception.NotAuthorized, - exception.OSMorphingSSHOperationTimeout, - ] - ) - def _exec_command(self, cmd, args=[], timeout=None, sanitizable=True): - command = windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) - if sanitizable: - sanitized_cmd = strutils.mask_password(command) - else: - sanitized_cmd = "***" - - timeout = int(timeout or self._conn_timeout) - LOG.debug("Executing SSH command: %s", sanitized_cmd) - try: - _, stdout, _stderr = self._ssh.exec_command(command, timeout=float(timeout)) - return self._read_ssh_exec_output(stdout.channel, timeout, sanitized_cmd) - except socket.timeout as ex: - raise exception.OSMorphingSSHOperationTimeout( - cmd=sanitized_cmd, timeout=timeout - ) from ex + def _exec_command(self, cmd, args=[], timeout=None): + return self._invoke_persistent_ps(_native_ps_script(cmd, args), timeout=timeout) def exec_command( self, @@ -520,7 +489,7 @@ def exec_command( ): if sanitizable: sanitized_cmd = strutils.mask_password( - windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) + "%s %s" % (cmd, " ".join(str(part) for part in (args or []))) ) else: sanitized_cmd = "***" @@ -529,9 +498,7 @@ def exec_command( # Close that process before reg.exe load or unload. self._close_ps_session(wait=True) LOG.debug("Executing Windows SSH command: %s", sanitized_cmd) - std_out, std_err, exit_code = self._exec_command( - cmd, args, timeout=timeout, sanitizable=sanitizable - ) + std_out, std_err, exit_code = self._exec_command(cmd, args, timeout=timeout) if exit_code: raise exception.CoriolisException( diff --git a/coriolis/windows_ssh_cmd.py b/coriolis/windows_ssh_cmd.py deleted file mode 100644 index 5647303f8..000000000 --- a/coriolis/windows_ssh_cmd.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2026 Cloudbase Solutions Srl -# All Rights Reserved. - -"""Convert WinRM native (cmd, args) pairs to an OpenSSH command line. - -Osmorphing builds native tool argv for WinRM. WinRM runs argv as-is. -SSH sends one string. OpenSSH DefaultShell is often PowerShell, so the -converter wraps cmd.exe /c and quotes for cmd.exe. - -WinRM arguments may contain quotes that were only argv delimiters, such -as /driver:"G:\\path". Those quotes must not stay in the DISM path. -""" - -_CMD_QUOTE_CHARS = (" ", "\t", '"', "&", "|", "(", ")", "<", ">", "^", "%") - - -def unwrap_winrm_arg_quotes(part): - """Remove WinRM argv quotes from one native argument.""" - text = str(part).strip() - changed = True - while changed and text: - changed = False - if len(text) >= 2 and text[0] == '"' and text[-1] == '"': - text = text[1:-1] - changed = True - continue - idx = text.find(':"') - if idx >= 0 and text.endswith('"'): - text = text[: idx + 1] + text[idx + 2 : -1] - changed = True - return text - - -def quote_cmd_arg(part): - part_str = unwrap_winrm_arg_quotes(part) - if (not part_str) or any(ch in part_str for ch in _CMD_QUOTE_CHARS): - return '"%s"' % part_str.replace('"', '""') - return part_str - - -def format_windows_command(cmd, args): - return " ".join(quote_cmd_arg(p) for p in [cmd] + list(args or [])) - - -def escape_trailing_backslash_for_ssh(command): - """Keep a trailing backslash from eating the SSH quote. - - Windows OpenSSH wraps the exec string in double quotes. An odd number - of trailing backslashes escapes that quote. PowerShell then reports a - missing string terminator. DISM /image:F:\\ is the usual case. - """ - n = len(command) - len(command.rstrip("\\")) - if n % 2 == 1: - return command + "\\" - return command - - -def wrap_native_command_for_ssh(command): - """Run native tools via cmd.exe. - - OpenSSH DefaultShell is often PowerShell. PowerShell parses (OI) in - icacls grants as a command. cmd.exe does not when the grant is quoted. - """ - command = escape_trailing_backslash_for_ssh(command) - return 'cmd.exe /c "%s"' % command.replace('"', '""') - - -def winrm_exec_to_ssh(cmd, args=None): - """Return the SSH exec string for a WinRM (cmd, args) pair.""" - formatted = format_windows_command(cmd, args) - wrapped = wrap_native_command_for_ssh(formatted) - return escape_trailing_backslash_for_ssh(wrapped) From a0d3bc8b82748a8bd97410b1d75cd4aa5a4b6bd6 Mon Sep 17 00:00:00 2001 From: Cristian Matiut Date: Fri, 2 Oct 2026 12:58:49 +0300 Subject: [PATCH 6/6] Add connection_type conf option for windows minion(ssh or winrm). --- coriolis/tests/test_windows_conn.py | 20 ++++++++++++++ coriolis/windows_conn.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/coriolis/tests/test_windows_conn.py b/coriolis/tests/test_windows_conn.py index a0856a03d..8b76caecc 100644 --- a/coriolis/tests/test_windows_conn.py +++ b/coriolis/tests/test_windows_conn.py @@ -28,3 +28,23 @@ def test_from_connection_info_ssh(self, mock_ssh, mock_winrm): windows_conn.from_connection_info(conn_info, timeout=30) mock_ssh.assert_called_once_with(conn_info, 30) mock_winrm.assert_not_called() + + def test_minion_connection_default_is_ssh(self): + windows_conn.CONF.clear_override("connection_type", group="windows_minion") + self.assertEqual("ssh", windows_conn.minion_connection()) + self.assertTrue(windows_conn.minion_uses_ssh()) + self.assertEqual(22, windows_conn.minion_port()) + + def test_minion_connection_winrm(self): + windows_conn.CONF.set_override( + "connection_type", "winrm", group="windows_minion" + ) + self.addCleanup( + windows_conn.CONF.clear_override, + "connection_type", + group="windows_minion", + ) + self.assertEqual("winrm", windows_conn.minion_connection()) + self.assertFalse(windows_conn.minion_uses_ssh()) + self.assertEqual(5986, windows_conn.minion_port()) + self.assertFalse(windows_conn.uses_winrm({"port": 22})) diff --git a/coriolis/windows_conn.py b/coriolis/windows_conn.py index 568ad7fdf..027f5e487 100644 --- a/coriolis/windows_conn.py +++ b/coriolis/windows_conn.py @@ -5,11 +5,54 @@ Port 5986 uses WinRM. Any other port, including 22, uses SSH. A missing port uses WinRM, which matches the old os-mount default. + +New Windows minions use [windows_minion] connection_type. ssh is port 22. +winrm is port 5986. A minion that is already deployed keeps the port +saved in connection_info. """ +from oslo_config import cfg + from coriolis import windows_ssh, wsman +SSH_PORT = 22 WINRM_HTTPS_PORT = 5986 +CONNECTION_SSH = "ssh" +CONNECTION_WINRM = "winrm" + +windows_minion_opts = [ + cfg.StrOpt( + "connection_type", + default=CONNECTION_SSH, + choices=[CONNECTION_SSH, CONNECTION_WINRM], + help=( + "Connection used for a new Windows minion. ssh starts OpenSSH " + "and uses port 22. winrm uses port 5986. The Windows template " + "must already accept WinRM when this is winrm. A minion that " + "is already deployed keeps the port in its connection_info." + ), + ), +] + +CONF = cfg.CONF +CONF.register_opts(windows_minion_opts, "windows_minion") + + +def minion_connection(): + """Return ssh or winrm for a new Windows minion.""" + return CONF.windows_minion.connection_type + + +def minion_uses_ssh(): + """Return True when a new Windows minion uses SSH.""" + return minion_connection() == CONNECTION_SSH + + +def minion_port(): + """Return 22 for ssh and 5986 for winrm.""" + if minion_uses_ssh(): + return SSH_PORT + return WINRM_HTTPS_PORT def uses_winrm(connection_info):