Coverage for nova/console/websocketproxy.py: 86%
184 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
1# Copyright (c) 2012 OpenStack Foundation
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.
16'''
17Websocket proxy that is compatible with OpenStack Nova.
18Leverages websockify.py by Joel Martin
19'''
21import copy
22from http import cookies as Cookie
23from http import HTTPStatus
24import os
25import socket
26from urllib import parse as urlparse
28from oslo_log import log as logging
29from oslo_utils import encodeutils
30import websockify
31from websockify import websockifyserver
33from nova.compute import rpcapi as compute_rpcapi
34import nova.conf
35from nova import context
36from nova import exception
37from nova.i18n import _
38from nova import objects
40from oslo_utils import timeutils
41import threading
43LOG = logging.getLogger(__name__)
45CONF = nova.conf.CONF
48class TenantSock(object):
49 """A socket wrapper for communicating with the tenant.
51 This class provides a socket-like interface to the internal
52 websockify send/receive queue for the client connection to
53 the tenant user. It is used with the security proxy classes.
54 """
56 def __init__(self, reqhandler):
57 self.reqhandler = reqhandler
58 self.queue = []
60 def recv(self, cnt):
61 # NB(sross): it's ok to block here because we know
62 # exactly the sequence of data arriving
63 while len(self.queue) < cnt:
64 # new_frames looks like ['abc', 'def']
65 new_frames, closed = self.reqhandler.recv_frames()
66 # flatten frames onto queue
67 for frame in new_frames:
68 self.queue.extend(
69 [bytes(chr(c), 'ascii') for c in frame])
71 if closed:
72 break
74 popped = self.queue[0:cnt]
75 del self.queue[0:cnt]
76 return b''.join(popped)
78 def sendall(self, data):
79 self.reqhandler.send_frames([encodeutils.safe_encode(data)])
81 def finish_up(self):
82 self.reqhandler.send_frames([b''.join(self.queue)])
84 def close(self):
85 self.finish_up()
86 self.reqhandler.send_close()
89class NovaProxyRequestHandler(websockify.ProxyRequestHandler):
91 def __init__(self, *args, **kwargs):
92 self._compute_rpcapi = None
93 websockify.ProxyRequestHandler.__init__(self, *args, **kwargs)
95 @property
96 def compute_rpcapi(self):
97 # Lazy load the rpcapi/ComputeAPI upon first use for this connection.
98 # This way, if we receive a TCP RST, we will not create a ComputeAPI
99 # object we won't use.
100 if not self._compute_rpcapi:
101 self._compute_rpcapi = compute_rpcapi.ComputeAPI()
102 return self._compute_rpcapi
104 def verify_origin_proto(self, connect_info, origin_proto):
105 if 'access_url_base' not in connect_info:
106 detail = _("No access_url_base in connect_info. "
107 "Cannot validate protocol")
108 raise exception.ValidationError(detail=detail)
110 expected_protos = [
111 urlparse.urlparse(connect_info.access_url_base).scheme]
112 # NOTE: For serial consoles the expected protocol could be ws or
113 # wss which correspond to http and https respectively in terms of
114 # security.
115 if 'ws' in expected_protos:
116 expected_protos.append('http')
117 if 'wss' in expected_protos:
118 expected_protos.append('https')
120 return origin_proto in expected_protos
122 def _check_console_port(self, ctxt, instance_uuid, port, console_type):
124 try:
125 instance = objects.Instance.get_by_uuid(ctxt, instance_uuid)
126 except exception.InstanceNotFound:
127 return
129 # NOTE(melwitt): The port is expected to be a str for validation.
130 return self.compute_rpcapi.validate_console_port(ctxt, instance,
131 str(port),
132 console_type)
134 def _get_connect_info(self, ctxt, token):
135 """Validate the token and get the connect info."""
136 # NOTE(PaulMurray) ConsoleAuthToken.validate validates the token.
137 # We call the compute manager directly to check the console port
138 # is correct.
139 connect_info = objects.ConsoleAuthToken.validate(ctxt, token)
141 valid_port = self._check_console_port(
142 ctxt, connect_info.instance_uuid, connect_info.port,
143 connect_info.console_type)
145 if not valid_port:
146 raise exception.InvalidToken(token='***')
148 return connect_info
150 def _close_connection(self, tsock, host, port):
151 """takes target socket and close the connection.
152 """
153 try:
154 tsock.shutdown(socket.SHUT_RDWR)
155 except OSError:
156 pass
157 finally:
158 if tsock.fileno() != -1: 158 ↛ exitline 158 didn't return from function '_close_connection' because the condition on line 158 was always true
159 tsock.close()
160 self.vmsg(_("%(host)s:%(port)s: "
161 "Websocket client or target closed") %
162 {'host': host, 'port': port})
164 def new_websocket_client(self):
165 """Called after a new WebSocket connection has been established."""
166 # Reopen the eventlet hub to make sure we don't share an epoll
167 # fd with parent and/or siblings, which would be bad
168 from eventlet import hubs
169 hubs.use_hub()
171 # The nova expected behavior is to have token
172 # passed to the method GET of the request
173 token = urlparse.parse_qs(
174 urlparse.urlparse(self.path).query
175 ).get('token', ['']).pop()
176 if not token:
177 # NoVNC uses it's own convention that forward token
178 # from the request to a cookie header, we should check
179 # also for this behavior
180 hcookie = self.headers.get('cookie')
181 if hcookie: 181 ↛ 195line 181 didn't jump to line 195 because the condition on line 181 was always true
182 cookie = Cookie.SimpleCookie()
183 for hcookie_part in hcookie.split(';'):
184 hcookie_part = hcookie_part.lstrip()
185 try:
186 cookie.load(hcookie_part)
187 except Cookie.CookieError:
188 # NOTE(stgleb): Do not print out cookie content
189 # for security reasons.
190 LOG.warning('Found malformed cookie')
191 else:
192 if 'token' in cookie: 192 ↛ 183line 192 didn't jump to line 183 because the condition on line 192 was always true
193 token = cookie['token'].value
195 ctxt = context.get_admin_context()
196 connect_info = self._get_connect_info(ctxt, token)
198 # Verify Origin
199 expected_origin_hostname = self.headers.get('Host')
200 if ':' in expected_origin_hostname: 200 ↛ 206line 200 didn't jump to line 206 because the condition on line 200 was always true
201 e = expected_origin_hostname
202 if '[' in e and ']' in e:
203 expected_origin_hostname = e.split(']')[0][1:]
204 else:
205 expected_origin_hostname = e.split(':')[0]
206 expected_origin_hostnames = CONF.console.allowed_origins
207 expected_origin_hostnames.append(expected_origin_hostname)
208 origin_url = self.headers.get('Origin')
209 # missing origin header indicates non-browser client which is OK
210 if origin_url is not None:
211 origin = urlparse.urlparse(origin_url)
212 origin_hostname = origin.hostname
213 origin_scheme = origin.scheme
214 # If the console connection was forwarded by a proxy (example:
215 # haproxy), the original protocol could be contained in the
216 # X-Forwarded-Proto header instead of the Origin header. Prefer the
217 # forwarded protocol if it is present.
218 forwarded_proto = self.headers.get('X-Forwarded-Proto')
219 if forwarded_proto is not None:
220 origin_scheme = forwarded_proto
221 if origin_hostname == '' or origin_scheme == '':
222 detail = _("Origin header not valid.")
223 raise exception.ValidationError(detail=detail)
224 if origin_hostname not in expected_origin_hostnames:
225 detail = _("Origin header does not match this host.")
226 raise exception.ValidationError(detail=detail)
227 if not self.verify_origin_proto(connect_info, origin_scheme):
228 detail = _("Origin header protocol does not match this host.")
229 raise exception.ValidationError(detail=detail)
231 sanitized_info = copy.copy(connect_info)
232 sanitized_info.token = '***'
233 self.msg(_('connect info: %s'), sanitized_info)
235 host = connect_info.host
236 port = connect_info.port
238 # Connect to the target
239 self.msg(_("connecting to: %(host)s:%(port)s") % {'host': host,
240 'port': port})
241 tsock = self.socket(host, port, connect=True)
243 # Handshake as necessary
244 if 'internal_access_path' in connect_info:
245 path = connect_info.internal_access_path
246 if path:
247 tsock.send(encodeutils.safe_encode(
248 'CONNECT %s HTTP/1.1\r\n\r\n' % path))
249 end_token = "\r\n\r\n"
250 while True:
251 data = tsock.recv(4096, socket.MSG_PEEK)
252 token_loc = data.find(end_token)
253 if token_loc != -1: 253 ↛ 250line 253 didn't jump to line 250 because the condition on line 253 was always true
254 if data.split("\r\n")[0].find("200") == -1:
255 raise exception.InvalidConnectionInfo()
256 # remove the response from recv buffer
257 tsock.recv(token_loc + len(end_token))
258 break
260 if self.server.security_proxy is not None:
261 tenant_sock = TenantSock(self)
263 try:
264 tsock = self.server.security_proxy.connect(tenant_sock, tsock)
265 except exception.SecurityProxyNegotiationFailed:
266 LOG.exception("Unable to perform security proxying, shutting "
267 "down connection")
268 tenant_sock.close()
269 tsock.shutdown(socket.SHUT_RDWR)
270 tsock.close()
271 raise
273 tenant_sock.finish_up()
275 # Start proxying
276 try:
277 if CONF.consoleauth.enforce_session_timeout:
278 conn_timeout = connect_info.expires - timeutils.utcnow_ts()
279 LOG.info('%s seconds to terminate connection.', conn_timeout)
280 threading.Timer(conn_timeout, self._close_connection,
281 [tsock, host, port]).start()
283 self.do_proxy(tsock)
284 except Exception:
285 self._close_connection(tsock, host, port)
286 raise
288 def socket(self, *args, **kwargs):
289 return websockifyserver.WebSockifyServer.socket(*args, **kwargs)
291 def send_head(self):
292 # This code is copied from this example patch:
293 # https://bugs.python.org/issue32084#msg306545
294 path = self.translate_path(self.path)
295 if os.path.isdir(path): 295 ↛ 305line 295 didn't jump to line 305 because the condition on line 295 was always true
296 parts = urlparse.urlsplit(self.path)
297 if not parts.path.endswith('/'): 297 ↛ 305line 297 didn't jump to line 305 because the condition on line 297 was always true
298 # Browsers interpret "Location: //uri" as an absolute URI
299 # like "http://URI"
300 if self.path.startswith('//'): 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 self.send_error(HTTPStatus.BAD_REQUEST,
302 "URI must not start with //")
303 return None
305 return super(NovaProxyRequestHandler, self).send_head()
308class NovaWebSocketProxy(websockify.WebSocketProxy):
309 def __init__(self, *args, **kwargs):
310 """:param security_proxy: instance of
311 nova.console.securityproxy.base.SecurityProxy
313 Create a new web socket proxy, optionally using the
314 @security_proxy instance to negotiate security layer
315 with the compute node.
316 """
317 self.security_proxy = kwargs.pop('security_proxy', None)
319 # If 'default' was specified as the ssl_minimum_version, we leave
320 # ssl_options unset to default to the underlying system defaults.
321 # We do this to avoid using websockify's behaviour for 'default'
322 # in select_ssl_version(), which hardcodes the versions to be
323 # quite relaxed and prevents us from using system crypto policies.
324 ssl_min_version = kwargs.pop('ssl_minimum_version', None)
325 if ssl_min_version and ssl_min_version != 'default':
326 kwargs['ssl_options'] = websockify.websocketproxy. \
327 select_ssl_version(ssl_min_version)
329 super(NovaWebSocketProxy, self).__init__(*args, **kwargs)
331 @staticmethod
332 def get_logger():
333 return LOG