blob: b4ba933beb6926b36acc72aa78b1109cd4865ee1 [file] [log] [blame]
ZhiQiang Fan39f97222013-09-20 04:49:44 +08001# Copyright 2012 OpenStack Foundation
Matthew Treinish72ea4422013-02-07 14:42:49 -05002# 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# Originally copied from python-glanceclient
17
18import copy
Matthew Treinish6900ba12013-02-19 16:38:01 -050019import hashlib
Matthew Treinish72ea4422013-02-07 14:42:49 -050020import httplib
Attila Fazekasc7920282013-03-01 13:04:54 +010021import json
Matthew Treinish72ea4422013-02-07 14:42:49 -050022import posixpath
Matthew Treinish6900ba12013-02-19 16:38:01 -050023import re
llg821243b20502014-02-22 10:32:49 +080024from six import moves
Matthew Treinish72ea4422013-02-07 14:42:49 -050025import socket
26import StringIO
27import struct
28import urlparse
29
Matthew Treinish72ea4422013-02-07 14:42:49 -050030
31# Python 2.5 compat fix
32if not hasattr(urlparse, 'parse_qsl'):
33 import cgi
34 urlparse.parse_qsl = cgi.parse_qsl
35
36import OpenSSL
37
38from tempest import exceptions as exc
Matthew Treinishf4a9b0f2013-07-26 16:58:26 -040039from tempest.openstack.common import log as logging
Matthew Treinish72ea4422013-02-07 14:42:49 -050040
41LOG = logging.getLogger(__name__)
42USER_AGENT = 'tempest'
43CHUNKSIZE = 1024 * 64 # 64kB
Matthew Treinish6900ba12013-02-19 16:38:01 -050044TOKEN_CHARS_RE = re.compile('^[-A-Za-z0-9+/=]*$')
Matthew Treinish72ea4422013-02-07 14:42:49 -050045
46
47class HTTPClient(object):
48
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000049 def __init__(self, auth_provider, filters, **kwargs):
50 self.auth_provider = auth_provider
51 self.filters = filters
52 self.endpoint = auth_provider.base_url(filters)
Matthew Treinish72ea4422013-02-07 14:42:49 -050053 endpoint_parts = self.parse_endpoint(self.endpoint)
54 self.endpoint_scheme = endpoint_parts.scheme
55 self.endpoint_hostname = endpoint_parts.hostname
56 self.endpoint_port = endpoint_parts.port
57 self.endpoint_path = endpoint_parts.path
58
59 self.connection_class = self.get_connection_class(self.endpoint_scheme)
60 self.connection_kwargs = self.get_connection_kwargs(
61 self.endpoint_scheme, **kwargs)
62
Matthew Treinish72ea4422013-02-07 14:42:49 -050063 @staticmethod
64 def parse_endpoint(endpoint):
65 return urlparse.urlparse(endpoint)
66
67 @staticmethod
68 def get_connection_class(scheme):
69 if scheme == 'https':
70 return VerifiedHTTPSConnection
71 else:
72 return httplib.HTTPConnection
73
74 @staticmethod
75 def get_connection_kwargs(scheme, **kwargs):
76 _kwargs = {'timeout': float(kwargs.get('timeout', 600))}
77
78 if scheme == 'https':
79 _kwargs['cacert'] = kwargs.get('cacert', None)
80 _kwargs['cert_file'] = kwargs.get('cert_file', None)
81 _kwargs['key_file'] = kwargs.get('key_file', None)
82 _kwargs['insecure'] = kwargs.get('insecure', False)
83 _kwargs['ssl_compression'] = kwargs.get('ssl_compression', True)
84
85 return _kwargs
86
87 def get_connection(self):
88 _class = self.connection_class
89 try:
90 return _class(self.endpoint_hostname, self.endpoint_port,
91 **self.connection_kwargs)
92 except httplib.InvalidURL:
93 raise exc.EndpointNotFound
94
Matthew Treinish72ea4422013-02-07 14:42:49 -050095 def _http_request(self, url, method, **kwargs):
Attila Fazekasb2902af2013-02-16 16:22:44 +010096 """Send an http request with the specified characteristics.
Matthew Treinish72ea4422013-02-07 14:42:49 -050097
98 Wrapper around httplib.HTTP(S)Connection.request to handle tasks such
99 as setting headers and error handling.
100 """
101 # Copy the kwargs so we can reuse the original in case of redirects
102 kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {}))
103 kwargs['headers'].setdefault('User-Agent', USER_AGENT)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500104
Matthew Treinish6900ba12013-02-19 16:38:01 -0500105 self._log_request(method, url, kwargs['headers'])
106
Matthew Treinish72ea4422013-02-07 14:42:49 -0500107 conn = self.get_connection()
108
109 try:
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000110 url_parts = self.parse_endpoint(url)
111 conn_url = posixpath.normpath(url_parts.path)
112 LOG.debug('Actual Path: {path}'.format(path=conn_url))
Matthew Treinish72ea4422013-02-07 14:42:49 -0500113 if kwargs['headers'].get('Transfer-Encoding') == 'chunked':
114 conn.putrequest(method, conn_url)
115 for header, value in kwargs['headers'].items():
116 conn.putheader(header, value)
117 conn.endheaders()
118 chunk = kwargs['body'].read(CHUNKSIZE)
119 # Chunk it, baby...
120 while chunk:
121 conn.send('%x\r\n%s\r\n' % (len(chunk), chunk))
122 chunk = kwargs['body'].read(CHUNKSIZE)
123 conn.send('0\r\n\r\n')
124 else:
125 conn.request(method, conn_url, **kwargs)
126 resp = conn.getresponse()
127 except socket.gaierror as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400128 message = ("Error finding address for %(url)s: %(e)s" %
129 {'url': url, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100130 raise exc.EndpointNotFound(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500131 except (socket.error, socket.timeout) as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400132 message = ("Error communicating with %(endpoint)s %(e)s" %
133 {'endpoint': self.endpoint, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100134 raise exc.TimeoutException(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500135
136 body_iter = ResponseBodyIterator(resp)
137
138 # Read body into string if it isn't obviously image data
139 if resp.getheader('content-type', None) != 'application/octet-stream':
Monty Taylorb2ca5ca2013-04-28 18:00:21 -0700140 body_str = ''.join([body_chunk for body_chunk in body_iter])
Matthew Treinish72ea4422013-02-07 14:42:49 -0500141 body_iter = StringIO.StringIO(body_str)
Matthew Treinish6900ba12013-02-19 16:38:01 -0500142 self._log_response(resp, None)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500143 else:
Matthew Treinish6900ba12013-02-19 16:38:01 -0500144 self._log_response(resp, body_iter)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500145
146 return resp, body_iter
147
Matthew Treinish6900ba12013-02-19 16:38:01 -0500148 def _log_request(self, method, url, headers):
149 LOG.info('Request: ' + method + ' ' + url)
150 if headers:
151 headers_out = headers
152 if 'X-Auth-Token' in headers and headers['X-Auth-Token']:
153 token = headers['X-Auth-Token']
154 if len(token) > 64 and TOKEN_CHARS_RE.match(token):
155 headers_out = headers.copy()
156 headers_out['X-Auth-Token'] = "<Token omitted>"
157 LOG.info('Request Headers: ' + str(headers_out))
158
159 def _log_response(self, resp, body):
160 status = str(resp.status)
161 LOG.info("Response Status: " + status)
162 if resp.getheaders():
163 LOG.info('Response Headers: ' + str(resp.getheaders()))
164 if body:
165 str_body = str(body)
166 length = len(body)
167 LOG.info('Response Body: ' + str_body[:2048])
168 if length >= 2048:
169 self.LOG.debug("Large body (%d) md5 summary: %s", length,
170 hashlib.md5(str_body).hexdigest())
171
Matthew Treinish72ea4422013-02-07 14:42:49 -0500172 def json_request(self, method, url, **kwargs):
173 kwargs.setdefault('headers', {})
174 kwargs['headers'].setdefault('Content-Type', 'application/json')
175
176 if 'body' in kwargs:
177 kwargs['body'] = json.dumps(kwargs['body'])
178
179 resp, body_iter = self._http_request(url, method, **kwargs)
180
181 if 'application/json' in resp.getheader('content-type', None):
182 body = ''.join([chunk for chunk in body_iter])
183 try:
184 body = json.loads(body)
185 except ValueError:
186 LOG.error('Could not decode response body as JSON')
187 else:
188 body = None
189
190 return resp, body
191
192 def raw_request(self, method, url, **kwargs):
193 kwargs.setdefault('headers', {})
194 kwargs['headers'].setdefault('Content-Type',
195 'application/octet-stream')
196 if 'body' in kwargs:
197 if (hasattr(kwargs['body'], 'read')
198 and method.lower() in ('post', 'put')):
199 # We use 'Transfer-Encoding: chunked' because
200 # body size may not always be known in advance.
201 kwargs['headers']['Transfer-Encoding'] = 'chunked'
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000202
203 # Decorate the request with auth
204 req_url, kwargs['headers'], kwargs['body'] = \
205 self.auth_provider.auth_request(
206 method=method, url=url, headers=kwargs['headers'],
207 body=kwargs.get('body', None), filters=self.filters)
208 return self._http_request(req_url, method, **kwargs)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500209
210
211class OpenSSLConnectionDelegator(object):
212 """
213 An OpenSSL.SSL.Connection delegator.
214
215 Supplies an additional 'makefile' method which httplib requires
216 and is not present in OpenSSL.SSL.Connection.
217
218 Note: Since it is not possible to inherit from OpenSSL.SSL.Connection
219 a delegator must be used.
220 """
221 def __init__(self, *args, **kwargs):
222 self.connection = OpenSSL.SSL.Connection(*args, **kwargs)
223
224 def __getattr__(self, name):
225 return getattr(self.connection, name)
226
227 def makefile(self, *args, **kwargs):
Matthew Treinishab23e902014-01-27 22:18:15 +0000228 # Ensure the socket is closed when this file is closed
229 kwargs['close'] = True
Matthew Treinish72ea4422013-02-07 14:42:49 -0500230 return socket._fileobject(self.connection, *args, **kwargs)
231
232
233class VerifiedHTTPSConnection(httplib.HTTPSConnection):
234 """
235 Extended HTTPSConnection which uses the OpenSSL library
236 for enhanced SSL support.
237 Note: Much of this functionality can eventually be replaced
238 with native Python 3.3 code.
239 """
240 def __init__(self, host, port=None, key_file=None, cert_file=None,
241 cacert=None, timeout=None, insecure=False,
242 ssl_compression=True):
243 httplib.HTTPSConnection.__init__(self, host, port,
244 key_file=key_file,
245 cert_file=cert_file)
246 self.key_file = key_file
247 self.cert_file = cert_file
248 self.timeout = timeout
249 self.insecure = insecure
250 self.ssl_compression = ssl_compression
251 self.cacert = cacert
252 self.setcontext()
253
254 @staticmethod
255 def host_matches_cert(host, x509):
256 """
257 Verify that the the x509 certificate we have received
258 from 'host' correctly identifies the server we are
259 connecting to, ie that the certificate's Common Name
260 or a Subject Alternative Name matches 'host'.
261 """
262 # First see if we can match the CN
263 if x509.get_subject().commonName == host:
264 return True
265
266 # Also try Subject Alternative Names for a match
267 san_list = None
llg821243b20502014-02-22 10:32:49 +0800268 for i in moves.xrange(x509.get_extension_count()):
Matthew Treinish72ea4422013-02-07 14:42:49 -0500269 ext = x509.get_extension(i)
270 if ext.get_short_name() == 'subjectAltName':
271 san_list = str(ext)
272 for san in ''.join(san_list.split()).split(','):
273 if san == "DNS:%s" % host:
274 return True
275
276 # Server certificate does not match host
277 msg = ('Host "%s" does not match x509 certificate contents: '
278 'CommonName "%s"' % (host, x509.get_subject().commonName))
279 if san_list is not None:
280 msg = msg + ', subjectAltName "%s"' % san_list
281 raise exc.SSLCertificateError(msg)
282
283 def verify_callback(self, connection, x509, errnum,
284 depth, preverify_ok):
285 if x509.has_expired():
286 msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
287 raise exc.SSLCertificateError(msg)
288
289 if depth == 0 and preverify_ok is True:
290 # We verify that the host matches against the last
291 # certificate in the chain
292 return self.host_matches_cert(self.host, x509)
293 else:
294 # Pass through OpenSSL's default result
295 return preverify_ok
296
297 def setcontext(self):
298 """
299 Set up the OpenSSL context.
300 """
301 self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
302
303 if self.ssl_compression is False:
304 self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION
305
306 if self.insecure is not True:
307 self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
308 self.verify_callback)
309 else:
310 self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
311 self.verify_callback)
312
313 if self.cert_file:
314 try:
315 self.context.use_certificate_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200316 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500317 msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
318 raise exc.SSLConfigurationError(msg)
319 if self.key_file is None:
320 # We support having key and cert in same file
321 try:
322 self.context.use_privatekey_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200323 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500324 msg = ('No key file specified and unable to load key '
325 'from "%s" %s' % (self.cert_file, e))
326 raise exc.SSLConfigurationError(msg)
327
328 if self.key_file:
329 try:
330 self.context.use_privatekey_file(self.key_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200331 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500332 msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
333 raise exc.SSLConfigurationError(msg)
334
335 if self.cacert:
336 try:
337 self.context.load_verify_locations(self.cacert)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200338 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500339 msg = 'Unable to load CA from "%s"' % (self.cacert, e)
340 raise exc.SSLConfigurationError(msg)
341 else:
342 self.context.set_default_verify_paths()
343
344 def connect(self):
345 """
346 Connect to an SSL port using the OpenSSL library and apply
347 per-connection parameters.
348 """
349 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
350 if self.timeout is not None:
351 # '0' microseconds
352 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
353 struct.pack('LL', self.timeout, 0))
354 self.sock = OpenSSLConnectionDelegator(self.context, sock)
355 self.sock.connect((self.host, self.port))
356
Matthew Treinishab23e902014-01-27 22:18:15 +0000357 def close(self):
358 if self.sock:
359 # Remove the reference to the socket but don't close it yet.
360 # Response close will close both socket and associated
361 # file. Closing socket too soon will cause response
362 # reads to fail with socket IO error 'Bad file descriptor'.
363 self.sock = None
Matthew Treinish7741cd62014-01-28 16:10:40 +0000364 httplib.HTTPSConnection.close(self)
Matthew Treinishab23e902014-01-27 22:18:15 +0000365
Matthew Treinish72ea4422013-02-07 14:42:49 -0500366
367class ResponseBodyIterator(object):
368 """A class that acts as an iterator over an HTTP response."""
369
370 def __init__(self, resp):
371 self.resp = resp
372
373 def __iter__(self):
374 while True:
375 yield self.next()
376
377 def next(self):
378 chunk = self.resp.read(CHUNKSIZE)
379 if chunk:
380 return chunk
381 else:
382 raise StopIteration()