Bring in tests against spice console
VNC has to do some extra handshake/negotiation as part of the RFB protocol, but
for SPICE console, it isn't neccesssary to get involved in the SPICE
protocol negotiation, since TLS is enabled before the protocol even
starts.
Closes-Bug: PRODX-37188
Change-Id: Ia6f21d529922e326822215f7125ab709855ea7d3
(cherry picked from commit 429d46a3ac460ffd040daba4e3cc1f34452b205f)
(cherry picked from commit d43f47049e59dc10ead1f62479d8b4dd5f890c6b)
diff --git a/requirements.txt b/requirements.txt
index c6dd58a..83410e2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,4 @@
defusedxml>=0.7.1 # PSFL
fasteners>=0.16.0 # Apache-2.0
tenacity>=4.4.0 # Apache-2.0
+websocket-client # LGPLv2+
diff --git a/tempest/api/compute/servers/test_console.py b/tempest/api/compute/servers/test_console.py
new file mode 100644
index 0000000..0cbeb41
--- /dev/null
+++ b/tempest/api/compute/servers/test_console.py
@@ -0,0 +1,327 @@
+# Copyright 2016-2017 OpenStack Foundation
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import ssl
+import struct
+import urllib.parse as urlparse
+import urllib3
+import websocket
+
+from tempest.api.compute import base
+from tempest.common import compute
+from tempest import config
+from tempest.lib import decorators
+
+CONF = config.CONF
+
+
+class ConsoleTestBase(base.BaseV2ComputeTest):
+ create_default_network = True
+
+ def setUp(self):
+ super(ConsoleTestBase, self).setUp()
+ self._websocket = None
+
+ def tearDown(self):
+ super(ConsoleTestBase, self).tearDown()
+ if self._websocket is not None:
+ self._websocket.close()
+ # NOTE(zhufl): Because server_check_teardown will raise Exception
+ # which will prevent other cleanup steps from being executed, so
+ # server_check_teardown should be called after super's tearDown.
+ self.server_check_teardown()
+
+ @classmethod
+ def setup_clients(cls):
+ super(ConsoleTestBase, cls).setup_clients()
+ cls.client = cls.servers_client
+
+ @classmethod
+ def resource_setup(cls):
+ super(ConsoleTestBase, cls).resource_setup()
+ cls.server = cls.create_test_server(wait_until="ACTIVE")
+ cls.use_get_remote_console = False
+ if not cls.is_requested_microversion_compatible("2.5"):
+ cls.use_get_remote_console = True
+
+ @property
+ def cert_params(self):
+ ssl_opt = {}
+ if CONF.identity.disable_ssl_certificate_validation:
+ ssl_opt["cert_reqs"] = ssl.CERT_NONE
+ else:
+ ssl_opt["ca_certs"] = CONF.identity.ca_certificates_file
+ return ssl_opt
+
+ def _validate_html(self, url, js_title):
+ """Verify we can connect to console and get back the javascript."""
+
+ resp = urllib3.PoolManager(**self.cert_params).request("GET", url)
+ # Make sure that the GET request was accepted by the console proxy
+ self.assertEqual(
+ resp.status,
+ 200,
+ "Got a Bad HTTP Response on the "
+ "initial call: " + str(resp.status),
+ )
+ # Do some basic validation to make sure it is an expected HTML document
+ resp_data = resp.data.decode()
+ # This is needed in the case of example: <html lang="en">
+ self.assertRegex(
+ resp_data, "<html.*>", "Not a valid html document in the response."
+ )
+ self.assertIn(
+ "</html>", resp_data, "Not a valid html document in the response."
+ )
+ # Just try to make sure we got JavaScript back for console, since we
+ # won't actually use it since not inside of a browser
+ self.assertIn(
+ js_title,
+ resp_data,
+ "Not a valid console javascript html document.",
+ )
+ self.assertIn(
+ "<script",
+ resp_data,
+ "Not a valid console javascript html document.",
+ )
+
+ def _validate_websocket_upgrade(self):
+ """Verify that the websocket upgrade was successful.
+
+ Parses response and ensures that required response
+ fields are present and accurate.
+ (https://tools.ietf.org/html/rfc7231#section-6.2.2)
+ """
+
+ self.assertTrue(
+ self._websocket.response.startswith(
+ b"HTTP/1.1 101 Switching Protocols"
+ ),
+ "Incorrect HTTP return status code: {}".format(
+ str(self._websocket.response)
+ ),
+ )
+ _required_header = "upgrade: websocket"
+ _response = str(self._websocket.response).lower()
+ self.assertIn(
+ _required_header,
+ _response,
+ "Did not get the expected WebSocket HTTP Response.",
+ )
+
+ def _get_console_body(self, type, protocol, get_console):
+ if self.use_get_remote_console:
+ return self.client.get_remote_console(
+ self.server["id"], type=type, protocol=protocol
+ )["remote_console"]
+ return getattr(self.client, get_console)(self.server["id"], type=type)[
+ "console"
+ ]
+
+ def _test_console_bad_token(self, type, protocol, get_console):
+ body = self._get_console_body(type, protocol, get_console)
+ self.assertEqual(type, body["type"])
+ # Do the WebSockify HTTP Request to console proxy with a bad token
+ parts = urlparse.urlparse(body["url"])
+ qparams = urlparse.parse_qs(parts.query)
+ if "path" in qparams:
+ qparams["path"] = urlparse.unquote(qparams["path"][0]).replace(
+ "token=", "token=bad"
+ )
+ elif "token" in qparams:
+ qparams["token"] = "bad" + qparams["token"][0]
+ new_query = urlparse.urlencode(qparams)
+ new_parts = urlparse.ParseResult(
+ parts.scheme,
+ parts.netloc,
+ parts.path,
+ parts.params,
+ new_query,
+ parts.fragment,
+ )
+ url = urlparse.urlunparse(new_parts)
+ self._websocket = compute.create_websocket(url)
+ # Make sure the console proxy rejected the connection and closed it
+ data = self._websocket.receive_frame()
+ self.assertTrue(
+ data is None or not data,
+ "The console proxy actually sent us some data, but we "
+ "expected it to close the connection.",
+ )
+
+
+class NoVNCConsoleTestJSON(ConsoleTestBase):
+ """Test novnc console"""
+
+ @classmethod
+ def skip_checks(cls):
+ super(NoVNCConsoleTestJSON, cls).skip_checks()
+ if not CONF.compute_feature_enabled.vnc_console:
+ raise cls.skipException("VNC Console feature is disabled.")
+
+ def _validate_rfb_negotiation(self):
+ """Verify we can connect to novnc and do the websocket connection."""
+ # Turn the Socket into a WebSocket to do the communication
+ data = self._websocket.receive_frame()
+ self.assertFalse(
+ data is None or not data,
+ "Token must be invalid because the connection closed.",
+ )
+ # Parse the RFB version from the data to make sure it is valid
+ # and belong to the known supported RFB versions.
+ version = float(
+ "%d.%d" % (int(data[4:7], base=10), int(data[8:11], base=10))
+ )
+ # Add the max RFB versions supported
+ supported_versions = [3.3, 3.8]
+ self.assertIn(
+ version, supported_versions, "Bad RFB Version: " + str(version)
+ )
+ # Send our RFB version to the server
+ self._websocket.send_frame(data)
+ # Get the sever authentication type and make sure None is supported
+ data = self._websocket.receive_frame()
+ self.assertIsNotNone(data, "Expected authentication type None.")
+ data_length = len(data)
+ if version == 3.3:
+ # For RFB 3.3: in the security handshake, rather than a two-way
+ # negotiation, the server decides the security type and sends a
+ # single word(4 bytes).
+ self.assertEqual(
+ data_length, 4, "Expected authentication type None."
+ )
+ self.assertIn(
+ 1,
+ [int(data[i]) for i in (0, 3)],
+ "Expected authentication type None.",
+ )
+ else:
+ self.assertGreaterEqual(
+ len(data), 2, "Expected authentication type None."
+ )
+ self.assertIn(
+ 1,
+ [int(data[i + 1]) for i in range(int(data[0]))],
+ "Expected authentication type None.",
+ )
+ # Send to the server that we only support authentication
+ # type None
+ self._websocket.send_frame(bytes((1,)))
+
+ # The server should send 4 bytes of 0's if security
+ # handshake succeeded
+ data = self._websocket.receive_frame()
+ self.assertEqual(
+ len(data), 4, "Server did not think security was successful."
+ )
+ self.assertEqual(
+ [int(i) for i in data],
+ [0, 0, 0, 0],
+ "Server did not think security was successful.",
+ )
+
+ # Say to leave the desktop as shared as part of client initialization
+ self._websocket.send_frame(bytes((1,)))
+ # Get the server initialization packet back and make sure it is the
+ # right structure where bytes 20-24 is the name length and
+ # 24-N is the name
+ data = self._websocket.receive_frame()
+ data_length = len(data) if data is not None else 0
+ self.assertFalse(
+ data_length <= 24 or
+ data_length != (struct.unpack(">L", data[20:24])[0] + 24),
+ "Server initialization was not the right format.",
+ )
+ # Since the rest of the data on the screen is arbitrary, we will
+ # close the socket and end our validation of the data at this point
+ # Assert that the latest check was false, meaning that the server
+ # initialization was the right format
+ self.assertFalse(
+ data_length <= 24 or
+ data_length != (struct.unpack(">L", data[20:24])[0] + 24)
+ )
+
+ @decorators.idempotent_id("c640fdff-8ab4-45a4-a5d8-7e6146cbd0dc")
+ def test_novnc(self):
+ """Test accessing novnc console of server"""
+ body = self._get_console_body("novnc", "vnc", "get_vnc_console")
+ self.assertEqual("novnc", body["type"])
+ # Do the initial HTTP Request to novncproxy to get the JavaScript
+ self._validate_html(body["url"], "noVNC")
+ # Do the WebSockify HTTP Request to novncproxy to do the RFB connection
+ self._websocket = compute.create_websocket(body["url"])
+ # Validate that we successfully connected and upgraded to Web Sockets
+ self._validate_websocket_upgrade()
+ # Validate the RFB Negotiation to determine if a valid VNC session
+ self._validate_rfb_negotiation()
+
+ @decorators.idempotent_id("f9c79937-addc-4aaa-9e0e-841eef02aeb7")
+ def test_novnc_bad_token(self):
+ """Test accessing novnc console with bad token
+
+ Do the WebSockify HTTP Request to novnc proxy with a bad token,
+ the novnc proxy should reject the connection and closed it.
+ """
+ self._test_console_bad_token("novnc", "vnc", "get_vnc_console")
+
+
+class SpiceConsoleTestJSON(ConsoleTestBase):
+ """Test spice console"""
+
+ @classmethod
+ def skip_checks(cls):
+ super(SpiceConsoleTestJSON, cls).skip_checks()
+ if not CONF.compute_feature_enabled.spice_console:
+ raise cls.skipException("SPICE Console feature is disabled.")
+
+ def _validate_websocket_connection(self, body):
+ # Protocol Magic number UINT8[4] { 0x52, 0x45, 0x44, 0x51} // "REDQ"
+ spice_magic = b"REDQ"
+ scheme = {"https": "wss", "http": "ws"}
+
+ q = urlparse.urlparse(body["url"])
+ ws = websocket.WebSocket(sslopt=self.cert_params)
+ ws.connect(
+ f"{scheme[q.scheme]}://{q.netloc}/websockify", cookie=q.query,
+ subprotocols=["binary"]
+ )
+ ws.send_binary(b"\r\n\r\n")
+ opcode, data = ws.recv_data()
+ self.assertEqual(opcode, websocket.ABNF.OPCODE_BINARY)
+ self.assertTrue(data.startswith(spice_magic))
+
+ @decorators.idempotent_id("0914a681-72dd-4fad-8457-b45195373d3d")
+ def test_spice(self):
+ """Test accessing spice console of server"""
+ body = self._get_console_body(
+ "spice-html5", "spice", "get_spice_console"
+ )
+ self.assertEqual("spice-html5", body["type"])
+ # Do the initial HTTP Request to spiceproxy to get the JavaScript
+ self._validate_html(body["url"], "Spice Javascript client")
+ # Validate that we successfully connected to Web Sockets
+ self._validate_websocket_connection(body)
+
+ @decorators.idempotent_id("6f4b0690-d078-4a28-a2ce-33dafdfca7ac")
+ def test_spice_bad_token(self):
+ """Test accessing spice console with bad token
+
+ Do the WebSockify HTTP Request to spice proxy with a bad token,
+ the spice proxy should reject the connection and closed it.
+ """
+ self._test_console_bad_token(
+ "spice-html5", "spice", "get_spice_console"
+ )
diff --git a/tempest/api/compute/servers/test_novnc.py b/tempest/api/compute/servers/test_novnc.py
deleted file mode 100644
index c90aea8..0000000
--- a/tempest/api/compute/servers/test_novnc.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# Copyright 2016-2017 OpenStack Foundation
-# All Rights Reserved.
-#
-# Licensed under the Apache License, Version 2.0 (the "License"); you may
-# not use this file except in compliance with the License. You may obtain
-# a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-# License for the specific language governing permissions and limitations
-# under the License.
-
-import struct
-import urllib.parse as urlparse
-import urllib3
-
-from tempest.api.compute import base
-from tempest.common import compute
-from tempest import config
-from tempest.lib import decorators
-
-CONF = config.CONF
-
-
-class NoVNCConsoleTestJSON(base.BaseV2ComputeTest):
- """Test novnc console"""
-
- create_default_network = True
-
- @classmethod
- def skip_checks(cls):
- super(NoVNCConsoleTestJSON, cls).skip_checks()
- if not CONF.compute_feature_enabled.vnc_console:
- raise cls.skipException('VNC Console feature is disabled.')
-
- def setUp(self):
- super(NoVNCConsoleTestJSON, self).setUp()
- self._websocket = None
-
- def tearDown(self):
- super(NoVNCConsoleTestJSON, self).tearDown()
- if self._websocket is not None:
- self._websocket.close()
- # NOTE(zhufl): Because server_check_teardown will raise Exception
- # which will prevent other cleanup steps from being executed, so
- # server_check_teardown should be called after super's tearDown.
- self.server_check_teardown()
-
- @classmethod
- def setup_clients(cls):
- super(NoVNCConsoleTestJSON, cls).setup_clients()
- cls.client = cls.servers_client
-
- @classmethod
- def resource_setup(cls):
- super(NoVNCConsoleTestJSON, cls).resource_setup()
- cls.server = cls.create_test_server(wait_until="ACTIVE")
- cls.use_get_remote_console = False
- if not cls.is_requested_microversion_compatible('2.5'):
- cls.use_get_remote_console = True
-
- def _validate_novnc_html(self, vnc_url):
- """Verify we can connect to novnc and get back the javascript."""
- cert_params = {}
-
- if CONF.identity.disable_ssl_certificate_validation:
- cert_params['cert_reqs'] = "CERT_NONE"
- else:
- cert_params["cert_reqs"] = "CERT_REQUIRED"
- cert_params["ca_certs"] = CONF.identity.ca_certificates_file
-
- resp = urllib3.PoolManager(**cert_params).request('GET', vnc_url)
- # Make sure that the GET request was accepted by the novncproxy
- self.assertEqual(resp.status, 200, 'Got a Bad HTTP Response on the '
- 'initial call: ' + str(resp.status))
- # Do some basic validation to make sure it is an expected HTML document
- resp_data = resp.data.decode()
- # This is needed in the case of example: <html lang="en">
- self.assertRegex(resp_data, '<html.*>',
- 'Not a valid html document in the response.')
- self.assertIn('</html>', resp_data,
- 'Not a valid html document in the response.')
- # Just try to make sure we got JavaScript back for noVNC, since we
- # won't actually use it since not inside of a browser
- self.assertIn('noVNC', resp_data,
- 'Not a valid noVNC javascript html document.')
- self.assertIn('<script', resp_data,
- 'Not a valid noVNC javascript html document.')
-
- def _validate_rfb_negotiation(self):
- """Verify we can connect to novnc and do the websocket connection."""
- # Turn the Socket into a WebSocket to do the communication
- data = self._websocket.receive_frame()
- self.assertFalse(data is None or not data,
- 'Token must be invalid because the connection '
- 'closed.')
- # Parse the RFB version from the data to make sure it is valid
- # and belong to the known supported RFB versions.
- version = float("%d.%d" % (int(data[4:7], base=10),
- int(data[8:11], base=10)))
- # Add the max RFB versions supported
- supported_versions = [3.3, 3.8]
- self.assertIn(version, supported_versions,
- 'Bad RFB Version: ' + str(version))
- # Send our RFB version to the server
- self._websocket.send_frame(data)
- # Get the sever authentication type and make sure None is supported
- data = self._websocket.receive_frame()
- self.assertIsNotNone(data, 'Expected authentication type None.')
- data_length = len(data)
- if version == 3.3:
- # For RFB 3.3: in the security handshake, rather than a two-way
- # negotiation, the server decides the security type and sends a
- # single word(4 bytes).
- self.assertEqual(
- data_length, 4, 'Expected authentication type None.')
- self.assertIn(1, [int(data[i]) for i in (0, 3)],
- 'Expected authentication type None.')
- else:
- self.assertGreaterEqual(
- len(data), 2, 'Expected authentication type None.')
- self.assertIn(
- 1,
- [int(data[i + 1]) for i in range(int(data[0]))],
- 'Expected authentication type None.')
- # Send to the server that we only support authentication
- # type None
- self._websocket.send_frame(bytes((1,)))
-
- # The server should send 4 bytes of 0's if security
- # handshake succeeded
- data = self._websocket.receive_frame()
- self.assertEqual(
- len(data), 4,
- 'Server did not think security was successful.')
- self.assertEqual(
- [int(i) for i in data], [0, 0, 0, 0],
- 'Server did not think security was successful.')
-
- # Say to leave the desktop as shared as part of client initialization
- self._websocket.send_frame(bytes((1,)))
- # Get the server initialization packet back and make sure it is the
- # right structure where bytes 20-24 is the name length and
- # 24-N is the name
- data = self._websocket.receive_frame()
- data_length = len(data) if data is not None else 0
- self.assertFalse(data_length <= 24 or
- data_length != (struct.unpack(">L",
- data[20:24])[0] + 24),
- 'Server initialization was not the right format.')
- # Since the rest of the data on the screen is arbitrary, we will
- # close the socket and end our validation of the data at this point
- # Assert that the latest check was false, meaning that the server
- # initialization was the right format
- self.assertFalse(data_length <= 24 or
- data_length != (struct.unpack(">L",
- data[20:24])[0] + 24))
-
- def _validate_websocket_upgrade(self):
- """Verify that the websocket upgrade was successful.
-
- Parses response and ensures that required response
- fields are present and accurate.
- (https://tools.ietf.org/html/rfc7231#section-6.2.2)
- """
-
- self.assertTrue(
- self._websocket.response.startswith(b'HTTP/1.1 101 Switching '
- b'Protocols'),
- 'Incorrect HTTP return status code: {}'.format(
- str(self._websocket.response)
- )
- )
- _required_header = 'upgrade: websocket'
- _response = str(self._websocket.response).lower()
- self.assertIn(
- _required_header,
- _response,
- 'Did not get the expected WebSocket HTTP Response.'
- )
-
- @decorators.idempotent_id('c640fdff-8ab4-45a4-a5d8-7e6146cbd0dc')
- def test_novnc(self):
- """Test accessing novnc console of server"""
- if self.use_get_remote_console:
- body = self.client.get_remote_console(
- self.server['id'], console_type='novnc',
- protocol='vnc')['remote_console']
- else:
- body = self.client.get_vnc_console(self.server['id'],
- type='novnc')['console']
- self.assertEqual('novnc', body['type'])
- # Do the initial HTTP Request to novncproxy to get the NoVNC JavaScript
- self._validate_novnc_html(body['url'])
- # Do the WebSockify HTTP Request to novncproxy to do the RFB connection
- self._websocket = compute.create_websocket(body['url'])
- # Validate that we successfully connected and upgraded to Web Sockets
- self._validate_websocket_upgrade()
- # Validate the RFB Negotiation to determine if a valid VNC session
- self._validate_rfb_negotiation()
-
- @decorators.idempotent_id('f9c79937-addc-4aaa-9e0e-841eef02aeb7')
- def test_novnc_bad_token(self):
- """Test accessing novnc console with bad token
-
- Do the WebSockify HTTP Request to novnc proxy with a bad token,
- the novnc proxy should reject the connection and closed it.
- """
- if self.use_get_remote_console:
- body = self.client.get_remote_console(
- self.server['id'], console_type='novnc',
- protocol='vnc')['remote_console']
- else:
- body = self.client.get_vnc_console(self.server['id'],
- type='novnc')['console']
- self.assertEqual('novnc', body['type'])
- # Do the WebSockify HTTP Request to novncproxy with a bad token
- parts = urlparse.urlparse(body['url'])
- qparams = urlparse.parse_qs(parts.query)
- if 'path' in qparams:
- qparams['path'] = urlparse.unquote(qparams['path'][0]).replace(
- 'token=', 'token=bad')
- elif 'token' in qparams:
- qparams['token'] = 'bad' + qparams['token'][0]
- new_query = urlparse.urlencode(qparams)
- new_parts = urlparse.ParseResult(parts.scheme, parts.netloc,
- parts.path, parts.params, new_query,
- parts.fragment)
- url = urlparse.urlunparse(new_parts)
- self._websocket = compute.create_websocket(url)
- # Make sure the novncproxy rejected the connection and closed it
- data = self._websocket.receive_frame()
- self.assertTrue(data is None or not data,
- "The novnc proxy actually sent us some data, but we "
- "expected it to close the connection.")
diff --git a/tempest/lib/api_schema/response/compute/v2_1/servers.py b/tempest/lib/api_schema/response/compute/v2_1/servers.py
index 14e2d3b..e066f7b 100644
--- a/tempest/lib/api_schema/response/compute/v2_1/servers.py
+++ b/tempest/lib/api_schema/response/compute/v2_1/servers.py
@@ -425,6 +425,8 @@
}
}
+get_spice_console = get_vnc_console
+
get_console_output = {
'status_code': [200],
'response_body': {
diff --git a/tempest/lib/services/compute/servers_client.py b/tempest/lib/services/compute/servers_client.py
index 274e62d..27834da 100644
--- a/tempest/lib/services/compute/servers_client.py
+++ b/tempest/lib/services/compute/servers_client.py
@@ -787,6 +787,16 @@
return self.action(server_id, "os-getVNCConsole",
schema.get_vnc_console, **kwargs)
+ def get_spice_console(self, server_id, **kwargs):
+ """Get URL of SPICE console.
+
+ For a full list of available parameters, please refer to the official
+ API reference:
+ https://docs.openstack.org/api-ref/compute/#get-spice-console-os-getspiceconsole-action-deprecated
+ """
+ return self.action(server_id, "os-getSPICEConsole",
+ schema.get_spice_console, **kwargs)
+
def add_fixed_ip(self, server_id, **kwargs):
"""Add a fixed IP to server instance.