Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 1 | # Copyright 2016-2017 OpenStack Foundation |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 2 | # All Rights Reserved. |
| 3 | # |
| 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 5 | # not use this file except in compliance with the License. You may obtain |
| 6 | # a copy of the License at |
| 7 | # |
| 8 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | # |
| 10 | # Unless required by applicable law or agreed to in writing, software |
| 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 13 | # License for the specific language governing permissions and limitations |
| 14 | # under the License. |
| 15 | |
| 16 | import socket |
| 17 | import struct |
| 18 | |
| 19 | import six |
| 20 | from six.moves.urllib import parse as urlparse |
| 21 | import urllib3 |
| 22 | |
| 23 | from tempest.api.compute import base |
| 24 | from tempest import config |
Ken'ichi Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 25 | from tempest.lib import decorators |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 26 | |
| 27 | CONF = config.CONF |
| 28 | |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 29 | if six.PY2: |
| 30 | ord_func = ord |
| 31 | else: |
| 32 | ord_func = int |
| 33 | |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 34 | |
| 35 | class NoVNCConsoleTestJSON(base.BaseV2ComputeTest): |
| 36 | |
| 37 | @classmethod |
| 38 | def skip_checks(cls): |
| 39 | super(NoVNCConsoleTestJSON, cls).skip_checks() |
| 40 | if not CONF.compute_feature_enabled.vnc_console: |
| 41 | raise cls.skipException('VNC Console feature is disabled.') |
| 42 | |
| 43 | def setUp(self): |
| 44 | super(NoVNCConsoleTestJSON, self).setUp() |
| 45 | self._websocket = None |
| 46 | |
| 47 | def tearDown(self): |
| 48 | self.server_check_teardown() |
| 49 | super(NoVNCConsoleTestJSON, self).tearDown() |
| 50 | if self._websocket is not None: |
| 51 | self._websocket.close() |
| 52 | |
| 53 | @classmethod |
| 54 | def setup_clients(cls): |
| 55 | super(NoVNCConsoleTestJSON, cls).setup_clients() |
| 56 | cls.client = cls.servers_client |
| 57 | |
| 58 | @classmethod |
| 59 | def resource_setup(cls): |
| 60 | super(NoVNCConsoleTestJSON, cls).resource_setup() |
| 61 | cls.server = cls.create_test_server(wait_until="ACTIVE") |
| 62 | |
| 63 | def _validate_novnc_html(self, vnc_url): |
| 64 | """Verify we can connect to novnc and get back the javascript.""" |
| 65 | resp = urllib3.PoolManager().request('GET', vnc_url) |
| 66 | # Make sure that the GET request was accepted by the novncproxy |
| 67 | self.assertEqual(resp.status, 200, 'Got a Bad HTTP Response on the ' |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 68 | 'initial call: ' + six.text_type(resp.status)) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 69 | # Do some basic validation to make sure it is an expected HTML document |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 70 | resp_data = resp.data.decode() |
| 71 | self.assertIn('<html>', resp_data, |
| 72 | 'Not a valid html document in the response.') |
| 73 | self.assertIn('</html>', resp_data, |
| 74 | 'Not a valid html document in the response.') |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 75 | # Just try to make sure we got JavaScript back for noVNC, since we |
| 76 | # won't actually use it since not inside of a browser |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 77 | self.assertIn('noVNC', resp_data, |
| 78 | 'Not a valid noVNC javascript html document.') |
| 79 | self.assertIn('<script', resp_data, |
| 80 | 'Not a valid noVNC javascript html document.') |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 81 | |
| 82 | def _validate_rfb_negotiation(self): |
| 83 | """Verify we can connect to novnc and do the websocket connection.""" |
| 84 | # Turn the Socket into a WebSocket to do the communication |
| 85 | data = self._websocket.receive_frame() |
| 86 | self.assertFalse(data is None or len(data) == 0, |
| 87 | 'Token must be invalid because the connection ' |
| 88 | 'closed.') |
| 89 | # Parse the RFB version from the data to make sure it is valid |
jianghua | 44a0f39 | 2017-03-13 04:14:26 +0000 | [diff] [blame] | 90 | # and belong to the known supported RFB versions. |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 91 | version = float("%d.%d" % (int(data[4:7], base=10), |
| 92 | int(data[8:11], base=10))) |
jianghua | 44a0f39 | 2017-03-13 04:14:26 +0000 | [diff] [blame] | 93 | # Add the max RFB versions supported |
| 94 | supported_versions = [3.3, 3.8] |
| 95 | self.assertIn(version, supported_versions, |
| 96 | 'Bad RFB Version: ' + str(version)) |
| 97 | # Send our RFB version to the server |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 98 | self._websocket.send_frame(data) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 99 | # Get the sever authentication type and make sure None is supported |
| 100 | data = self._websocket.receive_frame() |
| 101 | self.assertIsNotNone(data, 'Expected authentication type None.') |
jianghua | 44a0f39 | 2017-03-13 04:14:26 +0000 | [diff] [blame] | 102 | data_length = len(data) |
| 103 | if version == 3.3: |
| 104 | # For RFB 3.3: in the security handshake, rather than a two-way |
| 105 | # negotiation, the server decides the security type and sends a |
| 106 | # single word(4 bytes). |
| 107 | self.assertEqual( |
| 108 | data_length, 4, 'Expected authentication type None.') |
| 109 | self.assertIn(1, [ord_func(data[i]) for i in (0, 3)], |
| 110 | 'Expected authentication type None.') |
| 111 | else: |
| 112 | self.assertGreaterEqual( |
| 113 | len(data), 2, 'Expected authentication type None.') |
| 114 | self.assertIn( |
| 115 | 1, |
| 116 | [ord_func(data[i + 1]) for i in range(ord_func(data[0]))], |
| 117 | 'Expected authentication type None.') |
| 118 | # Send to the server that we only support authentication |
| 119 | # type None |
| 120 | self._websocket.send_frame(six.int2byte(1)) |
| 121 | |
| 122 | # The server should send 4 bytes of 0's if security |
| 123 | # handshake succeeded |
| 124 | data = self._websocket.receive_frame() |
| 125 | self.assertEqual( |
| 126 | len(data), 4, |
| 127 | 'Server did not think security was successful.') |
| 128 | self.assertEqual( |
| 129 | [ord_func(i) for i in data], [0, 0, 0, 0], |
| 130 | 'Server did not think security was successful.') |
| 131 | |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 132 | # Say to leave the desktop as shared as part of client initialization |
| 133 | self._websocket.send_frame(six.int2byte(1)) |
| 134 | # Get the server initialization packet back and make sure it is the |
| 135 | # right structure where bytes 20-24 is the name length and |
| 136 | # 24-N is the name |
| 137 | data = self._websocket.receive_frame() |
| 138 | data_length = len(data) if data is not None else 0 |
| 139 | self.assertFalse(data_length <= 24 or |
| 140 | data_length != (struct.unpack(">L", |
| 141 | data[20:24])[0] + 24), |
| 142 | 'Server initialization was not the right format.') |
| 143 | # Since the rest of the data on the screen is arbitrary, we will |
| 144 | # close the socket and end our validation of the data at this point |
| 145 | # Assert that the latest check was false, meaning that the server |
| 146 | # initialization was the right format |
| 147 | self.assertFalse(data_length <= 24 or |
| 148 | data_length != (struct.unpack(">L", |
| 149 | data[20:24])[0] + 24)) |
| 150 | |
| 151 | def _validate_websocket_upgrade(self): |
| 152 | self.assertTrue( |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 153 | self._websocket.response.startswith(b'HTTP/1.1 101 Switching ' |
| 154 | b'Protocols\r\n'), |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 155 | 'Did not get the expected 101 on the websockify call: ' |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 156 | + six.text_type(self._websocket.response)) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 157 | self.assertTrue( |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 158 | self._websocket.response.find(b'Server: WebSockify') > 0, |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 159 | 'Did not get the expected WebSocket HTTP Response.') |
| 160 | |
| 161 | def _create_websocket(self, url): |
| 162 | url = urlparse.urlparse(url) |
| 163 | client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 164 | client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 165 | client_socket.connect((url.hostname, url.port)) |
| 166 | # Turn the Socket into a WebSocket to do the communication |
| 167 | return _WebSocket(client_socket, url) |
| 168 | |
Ken'ichi Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 169 | @decorators.idempotent_id('c640fdff-8ab4-45a4-a5d8-7e6146cbd0dc') |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 170 | def test_novnc(self): |
| 171 | body = self.client.get_vnc_console(self.server['id'], |
| 172 | type='novnc')['console'] |
| 173 | self.assertEqual('novnc', body['type']) |
| 174 | # Do the initial HTTP Request to novncproxy to get the NoVNC JavaScript |
| 175 | self._validate_novnc_html(body['url']) |
| 176 | # Do the WebSockify HTTP Request to novncproxy to do the RFB connection |
| 177 | self._websocket = self._create_websocket(body['url']) |
| 178 | # Validate that we succesfully connected and upgraded to Web Sockets |
| 179 | self._validate_websocket_upgrade() |
| 180 | # Validate the RFB Negotiation to determine if a valid VNC session |
| 181 | self._validate_rfb_negotiation() |
| 182 | |
Ken'ichi Ohmichi | 44f0127 | 2017-01-27 18:44:14 -0800 | [diff] [blame] | 183 | @decorators.idempotent_id('f9c79937-addc-4aaa-9e0e-841eef02aeb7') |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 184 | def test_novnc_bad_token(self): |
| 185 | body = self.client.get_vnc_console(self.server['id'], |
| 186 | type='novnc')['console'] |
| 187 | self.assertEqual('novnc', body['type']) |
| 188 | # Do the WebSockify HTTP Request to novncproxy with a bad token |
| 189 | url = body['url'].replace('token=', 'token=bad') |
| 190 | self._websocket = self._create_websocket(url) |
| 191 | # Make sure the novncproxy rejected the connection and closed it |
| 192 | data = self._websocket.receive_frame() |
| 193 | self.assertTrue(data is None or len(data) == 0, |
| 194 | "The novnc proxy actually sent us some data, but we " |
| 195 | "expected it to close the connection.") |
| 196 | |
| 197 | |
| 198 | class _WebSocket(object): |
| 199 | def __init__(self, client_socket, url): |
| 200 | """Contructor for the WebSocket wrapper to the socket.""" |
| 201 | self._socket = client_socket |
| 202 | # Upgrade the HTTP connection to a WebSocket |
| 203 | self._upgrade(url) |
| 204 | |
| 205 | def receive_frame(self): |
| 206 | """Wrapper for receiving data to parse the WebSocket frame format""" |
| 207 | # We need to loop until we either get some bytes back in the frame |
| 208 | # or no data was received (meaning the socket was closed). This is |
| 209 | # done to handle the case where we get back some empty frames |
| 210 | while True: |
| 211 | header = self._socket.recv(2) |
| 212 | # If we didn't receive any data, just return None |
| 213 | if len(header) == 0: |
| 214 | return None |
| 215 | # We will make the assumption that we are only dealing with |
| 216 | # frames less than 125 bytes here (for the negotiation) and |
| 217 | # that only the 2nd byte contains the length, and since the |
| 218 | # server doesn't do masking, we can just read the data length |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 219 | if ord_func(header[1]) & 127 > 0: |
| 220 | return self._socket.recv(ord_func(header[1]) & 127) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 221 | |
| 222 | def send_frame(self, data): |
| 223 | """Wrapper for sending data to add in the WebSocket frame format.""" |
| 224 | frame_bytes = list() |
| 225 | # For the first byte, want to say we are sending binary data (130) |
| 226 | frame_bytes.append(130) |
| 227 | # Only sending negotiation data so don't need to worry about > 125 |
| 228 | # We do need to add the bit that says we are masking the data |
| 229 | frame_bytes.append(len(data) | 128) |
| 230 | # We don't really care about providing a random mask for security |
| 231 | # So we will just hard-code a value since a test program |
| 232 | mask = [7, 2, 1, 9] |
| 233 | for i in range(len(mask)): |
| 234 | frame_bytes.append(mask[i]) |
| 235 | # Mask each of the actual data bytes that we are going to send |
| 236 | for i in range(len(data)): |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 237 | frame_bytes.append(ord_func(data[i]) ^ mask[i % 4]) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 238 | # Convert our integer list to a binary array of bytes |
| 239 | frame_bytes = struct.pack('!%iB' % len(frame_bytes), * frame_bytes) |
| 240 | self._socket.sendall(frame_bytes) |
| 241 | |
| 242 | def close(self): |
| 243 | """Helper method to close the connection.""" |
| 244 | # Close down the real socket connection and exit the test program |
| 245 | if self._socket is not None: |
| 246 | self._socket.shutdown(1) |
| 247 | self._socket.close() |
| 248 | self._socket = None |
| 249 | |
| 250 | def _upgrade(self, url): |
| 251 | """Upgrade the HTTP connection to a WebSocket and verify.""" |
| 252 | # The real request goes to the /websockify URI always |
| 253 | reqdata = 'GET /websockify HTTP/1.1\r\n' |
| 254 | reqdata += 'Host: %s:%s\r\n' % (url.hostname, url.port) |
| 255 | # Tell the HTTP Server to Upgrade the connection to a WebSocket |
| 256 | reqdata += 'Upgrade: websocket\r\nConnection: Upgrade\r\n' |
| 257 | # The token=xxx is sent as a Cookie not in the URI |
| 258 | reqdata += 'Cookie: %s\r\n' % url.query |
| 259 | # Use a hard-coded WebSocket key since a test program |
| 260 | reqdata += 'Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n' |
| 261 | reqdata += 'Sec-WebSocket-Version: 13\r\n' |
| 262 | # We are choosing to use binary even though browser may do Base64 |
| 263 | reqdata += 'Sec-WebSocket-Protocol: binary\r\n\r\n' |
| 264 | # Send the HTTP GET request and get the response back |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 265 | self._socket.sendall(reqdata.encode('utf8')) |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 266 | self.response = data = self._socket.recv(4096) |
| 267 | # Loop through & concatenate all of the data in the response body |
Matthew Treinish | 90e2a6d | 2017-02-06 19:56:43 -0500 | [diff] [blame] | 268 | while len(data) > 0 and self.response.find(b'\r\n\r\n') < 0: |
Michelle Mandel | 1f87a56 | 2016-07-15 17:11:33 -0400 | [diff] [blame] | 269 | data = self._socket.recv(4096) |
| 270 | self.response += data |