diff --git a/coriolis/osmorphing/osmount/windows.py b/coriolis/osmorphing/osmount/windows.py index 9dfea037..15f51322 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_conn from coriolis.osmorphing.osmount import base LOG = logging.getLogger(__name__) @@ -22,14 +22,19 @@ def __init__(self, *args, **kwargs): def _connect(self): connection_info = self._connection_info - host = connection_info["ip"] - port = connection_info.get("port", 5986) - self._event_manager.progress_update( - "Connecting to WinRM host: %(host)s:%(port)s" % {"host": host, "port": port} - ) + 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 = wsman.WSManConnection.from_connection_info( + self._conn = windows_conn.from_connection_info( connection_info, self._osmount_operation_timeout ) diff --git a/coriolis/providers/provider_utils.py b/coriolis/providers/provider_utils.py index e13be272..00967851 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, windows_conn, wsman 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. @@ -225,9 +229,17 @@ def _poll_instance_until_reachable_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: @@ -239,22 +251,27 @@ def poll_instance_until_reachable( * username * password * pkey - Paramiko keypair - :param protocol: connection protocol, "ssh" or "winrm" + :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 """ - # 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: - 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, ) - return helper( - connection_info=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 3d2ffe22..7ac7f974 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_conn, 'from_connection_info') + def setUp(self, mock_from_connection_info): super(WindowsMountToolsTestCase, self).setUp() self.event_manager = mock.MagicMock() self.ssh = mock.MagicMock() @@ -43,9 +43,9 @@ 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_conn, 'from_connection_info') def test__connect(self, mock_from_connection_info): result = self.tools._connect() self.assertIsNone(result) diff --git a/coriolis/tests/providers/test_provider_utils.py b/coriolis/tests/providers/test_provider_utils.py index 7884019b..72e5fc4a 100644 --- a/coriolis/tests/providers/test_provider_utils.py +++ b/coriolis/tests/providers/test_provider_utils.py @@ -351,6 +351,21 @@ def test_poll_instance_ssh_timeout( poll_interval=poll_interval, ) + 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, + ) + @mock.patch("coriolis.wsman.WSManConnection", new_callable=mock.Mock) @mock.patch("time.sleep") def test_poll_instance_winrm( @@ -400,3 +415,12 @@ def test_poll_instance_winrm_timeout( timeout=30, poll_interval=poll_interval, ) + + def test_poll_instance_unsupported_protocol(self): + connection_info = self._get_mock_conn_info() + self.assertRaises( + exception.InvalidInput, + provider_utils.poll_instance_until_reachable, + connection_info=connection_info, + protocol="ftp", + ) diff --git a/coriolis/tests/test_windows_conn.py b/coriolis/tests/test_windows_conn.py new file mode 100644 index 00000000..a0856a03 --- /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 new file mode 100644 index 00000000..233ac882 --- /dev/null +++ b/coriolis/tests/test_windows_ssh.py @@ -0,0 +1,501 @@ +# 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_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._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.exec_command("dism.exe", ["/Get-WimInfo"]) + self.conn._close_ps_session.assert_not_called() + + 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_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") + self.assertEqual(windows_ssh._strip_ps_output("\n1\n\n2\n"), "1\r\n2") + + 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("Out-String", 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_windows_ssh_cmd.py b/coriolis/tests/test_windows_ssh_cmd.py new file mode 100644 index 00000000..fd833396 --- /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/windows_conn.py b/coriolis/windows_conn.py new file mode 100644 index 00000000..568ad7fd --- /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 new file mode 100644 index 00000000..0c247d0f --- /dev/null +++ b/coriolis/windows_ssh.py @@ -0,0 +1,599 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""SSH connection for Windows OS morphing minions. + +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 +------------------- +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 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). +""" + +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, windows_ssh_cmd + +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") + + +def _strip_ps_output(stdout): + """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): + """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 _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 | " + "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) + ) + + @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 = 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, + sanitizable=True, + include_stderr=False, + ): + if sanitizable: + sanitized_cmd = strutils.mask_password( + windows_ssh_cmd.winrm_exec_to_ssh(cmd, args) + ) + else: + sanitized_cmd = "***" + if _is_reg_exe(cmd): + # 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 + ) + + 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/windows_ssh_cmd.py b/coriolis/windows_ssh_cmd.py new file mode 100644 index 00000000..5647303f --- /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/requirements.txt b/requirements.txt index 89d38ee7..ad91a41a 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 @@ -34,7 +35,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