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

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. 

15 

16''' 

17Websocket proxy that is compatible with OpenStack Nova. 

18Leverages websockify.py by Joel Martin 

19''' 

20 

21import copy 

22from http import cookies as Cookie 

23from http import HTTPStatus 

24import os 

25import socket 

26from urllib import parse as urlparse 

27 

28from oslo_log import log as logging 

29from oslo_utils import encodeutils 

30import websockify 

31from websockify import websockifyserver 

32 

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 

39 

40from oslo_utils import timeutils 

41import threading 

42 

43LOG = logging.getLogger(__name__) 

44 

45CONF = nova.conf.CONF 

46 

47 

48class TenantSock(object): 

49 """A socket wrapper for communicating with the tenant. 

50 

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 """ 

55 

56 def __init__(self, reqhandler): 

57 self.reqhandler = reqhandler 

58 self.queue = [] 

59 

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]) 

70 

71 if closed: 

72 break 

73 

74 popped = self.queue[0:cnt] 

75 del self.queue[0:cnt] 

76 return b''.join(popped) 

77 

78 def sendall(self, data): 

79 self.reqhandler.send_frames([encodeutils.safe_encode(data)]) 

80 

81 def finish_up(self): 

82 self.reqhandler.send_frames([b''.join(self.queue)]) 

83 

84 def close(self): 

85 self.finish_up() 

86 self.reqhandler.send_close() 

87 

88 

89class NovaProxyRequestHandler(websockify.ProxyRequestHandler): 

90 

91 def __init__(self, *args, **kwargs): 

92 self._compute_rpcapi = None 

93 websockify.ProxyRequestHandler.__init__(self, *args, **kwargs) 

94 

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 

103 

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) 

109 

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') 

119 

120 return origin_proto in expected_protos 

121 

122 def _check_console_port(self, ctxt, instance_uuid, port, console_type): 

123 

124 try: 

125 instance = objects.Instance.get_by_uuid(ctxt, instance_uuid) 

126 except exception.InstanceNotFound: 

127 return 

128 

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) 

133 

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) 

140 

141 valid_port = self._check_console_port( 

142 ctxt, connect_info.instance_uuid, connect_info.port, 

143 connect_info.console_type) 

144 

145 if not valid_port: 

146 raise exception.InvalidToken(token='***') 

147 

148 return connect_info 

149 

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}) 

163 

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() 

170 

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 

194 

195 ctxt = context.get_admin_context() 

196 connect_info = self._get_connect_info(ctxt, token) 

197 

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) 

230 

231 sanitized_info = copy.copy(connect_info) 

232 sanitized_info.token = '***' 

233 self.msg(_('connect info: %s'), sanitized_info) 

234 

235 host = connect_info.host 

236 port = connect_info.port 

237 

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) 

242 

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 

259 

260 if self.server.security_proxy is not None: 

261 tenant_sock = TenantSock(self) 

262 

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 

272 

273 tenant_sock.finish_up() 

274 

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() 

282 

283 self.do_proxy(tsock) 

284 except Exception: 

285 self._close_connection(tsock, host, port) 

286 raise 

287 

288 def socket(self, *args, **kwargs): 

289 return websockifyserver.WebSockifyServer.socket(*args, **kwargs) 

290 

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 

304 

305 return super(NovaProxyRequestHandler, self).send_head() 

306 

307 

308class NovaWebSocketProxy(websockify.WebSocketProxy): 

309 def __init__(self, *args, **kwargs): 

310 """:param security_proxy: instance of 

311 nova.console.securityproxy.base.SecurityProxy 

312 

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) 

318 

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) 

328 

329 super(NovaWebSocketProxy, self).__init__(*args, **kwargs) 

330 

331 @staticmethod 

332 def get_logger(): 

333 return LOG