blob: 55aca5ae587b2fb076a8e7185af22d1ec5e22c95 [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
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -050022import OpenSSL
Matthew Treinish72ea4422013-02-07 14:42:49 -050023import posixpath
Matthew Treinish6900ba12013-02-19 16:38:01 -050024import re
llg821243b20502014-02-22 10:32:49 +080025from six import moves
Matthew Treinish72ea4422013-02-07 14:42:49 -050026import socket
27import StringIO
28import struct
29import urlparse
30
Matthew Treinish72ea4422013-02-07 14:42:49 -050031from tempest import exceptions as exc
Matthew Treinishf4a9b0f2013-07-26 16:58:26 -040032from tempest.openstack.common import log as logging
Matthew Treinish72ea4422013-02-07 14:42:49 -050033
34LOG = logging.getLogger(__name__)
35USER_AGENT = 'tempest'
36CHUNKSIZE = 1024 * 64 # 64kB
Matthew Treinish6900ba12013-02-19 16:38:01 -050037TOKEN_CHARS_RE = re.compile('^[-A-Za-z0-9+/=]*$')
Matthew Treinish72ea4422013-02-07 14:42:49 -050038
39
40class HTTPClient(object):
41
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000042 def __init__(self, auth_provider, filters, **kwargs):
43 self.auth_provider = auth_provider
44 self.filters = filters
45 self.endpoint = auth_provider.base_url(filters)
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -050046 endpoint_parts = urlparse.urlparse(self.endpoint)
Matthew Treinish72ea4422013-02-07 14:42:49 -050047 self.endpoint_scheme = endpoint_parts.scheme
48 self.endpoint_hostname = endpoint_parts.hostname
49 self.endpoint_port = endpoint_parts.port
50 self.endpoint_path = endpoint_parts.path
51
52 self.connection_class = self.get_connection_class(self.endpoint_scheme)
53 self.connection_kwargs = self.get_connection_kwargs(
54 self.endpoint_scheme, **kwargs)
55
Matthew Treinish72ea4422013-02-07 14:42:49 -050056 @staticmethod
Matthew Treinish72ea4422013-02-07 14:42:49 -050057 def get_connection_class(scheme):
58 if scheme == 'https':
59 return VerifiedHTTPSConnection
60 else:
61 return httplib.HTTPConnection
62
63 @staticmethod
64 def get_connection_kwargs(scheme, **kwargs):
65 _kwargs = {'timeout': float(kwargs.get('timeout', 600))}
66
67 if scheme == 'https':
68 _kwargs['cacert'] = kwargs.get('cacert', None)
69 _kwargs['cert_file'] = kwargs.get('cert_file', None)
70 _kwargs['key_file'] = kwargs.get('key_file', None)
71 _kwargs['insecure'] = kwargs.get('insecure', False)
72 _kwargs['ssl_compression'] = kwargs.get('ssl_compression', True)
73
74 return _kwargs
75
76 def get_connection(self):
77 _class = self.connection_class
78 try:
79 return _class(self.endpoint_hostname, self.endpoint_port,
80 **self.connection_kwargs)
81 except httplib.InvalidURL:
82 raise exc.EndpointNotFound
83
Matthew Treinish72ea4422013-02-07 14:42:49 -050084 def _http_request(self, url, method, **kwargs):
Attila Fazekasb2902af2013-02-16 16:22:44 +010085 """Send an http request with the specified characteristics.
Matthew Treinish72ea4422013-02-07 14:42:49 -050086
87 Wrapper around httplib.HTTP(S)Connection.request to handle tasks such
88 as setting headers and error handling.
89 """
90 # Copy the kwargs so we can reuse the original in case of redirects
91 kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {}))
92 kwargs['headers'].setdefault('User-Agent', USER_AGENT)
Matthew Treinish72ea4422013-02-07 14:42:49 -050093
Matthew Treinish6900ba12013-02-19 16:38:01 -050094 self._log_request(method, url, kwargs['headers'])
95
Matthew Treinish72ea4422013-02-07 14:42:49 -050096 conn = self.get_connection()
97
98 try:
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -050099 url_parts = urlparse.urlparse(url)
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000100 conn_url = posixpath.normpath(url_parts.path)
101 LOG.debug('Actual Path: {path}'.format(path=conn_url))
Matthew Treinish72ea4422013-02-07 14:42:49 -0500102 if kwargs['headers'].get('Transfer-Encoding') == 'chunked':
103 conn.putrequest(method, conn_url)
104 for header, value in kwargs['headers'].items():
105 conn.putheader(header, value)
106 conn.endheaders()
107 chunk = kwargs['body'].read(CHUNKSIZE)
108 # Chunk it, baby...
109 while chunk:
110 conn.send('%x\r\n%s\r\n' % (len(chunk), chunk))
111 chunk = kwargs['body'].read(CHUNKSIZE)
112 conn.send('0\r\n\r\n')
113 else:
114 conn.request(method, conn_url, **kwargs)
115 resp = conn.getresponse()
116 except socket.gaierror as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400117 message = ("Error finding address for %(url)s: %(e)s" %
118 {'url': url, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100119 raise exc.EndpointNotFound(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500120 except (socket.error, socket.timeout) as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400121 message = ("Error communicating with %(endpoint)s %(e)s" %
122 {'endpoint': self.endpoint, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100123 raise exc.TimeoutException(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500124
125 body_iter = ResponseBodyIterator(resp)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500126 # Read body into string if it isn't obviously image data
127 if resp.getheader('content-type', None) != 'application/octet-stream':
Monty Taylorb2ca5ca2013-04-28 18:00:21 -0700128 body_str = ''.join([body_chunk for body_chunk in body_iter])
Matthew Treinish72ea4422013-02-07 14:42:49 -0500129 body_iter = StringIO.StringIO(body_str)
Matthew Treinish6900ba12013-02-19 16:38:01 -0500130 self._log_response(resp, None)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500131 else:
Matthew Treinish6900ba12013-02-19 16:38:01 -0500132 self._log_response(resp, body_iter)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500133
134 return resp, body_iter
135
Matthew Treinish6900ba12013-02-19 16:38:01 -0500136 def _log_request(self, method, url, headers):
137 LOG.info('Request: ' + method + ' ' + url)
138 if headers:
139 headers_out = headers
140 if 'X-Auth-Token' in headers and headers['X-Auth-Token']:
141 token = headers['X-Auth-Token']
142 if len(token) > 64 and TOKEN_CHARS_RE.match(token):
143 headers_out = headers.copy()
144 headers_out['X-Auth-Token'] = "<Token omitted>"
145 LOG.info('Request Headers: ' + str(headers_out))
146
147 def _log_response(self, resp, body):
148 status = str(resp.status)
149 LOG.info("Response Status: " + status)
150 if resp.getheaders():
151 LOG.info('Response Headers: ' + str(resp.getheaders()))
152 if body:
153 str_body = str(body)
154 length = len(body)
155 LOG.info('Response Body: ' + str_body[:2048])
156 if length >= 2048:
157 self.LOG.debug("Large body (%d) md5 summary: %s", length,
158 hashlib.md5(str_body).hexdigest())
159
Matthew Treinish72ea4422013-02-07 14:42:49 -0500160 def json_request(self, method, url, **kwargs):
161 kwargs.setdefault('headers', {})
162 kwargs['headers'].setdefault('Content-Type', 'application/json')
Mauro S. M. Rodrigues5403b792014-05-06 15:24:54 -0400163 if kwargs['headers']['Content-Type'] != 'application/json':
164 msg = "Only application/json content-type is supported."
165 raise exc.InvalidContentType(msg)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500166
167 if 'body' in kwargs:
168 kwargs['body'] = json.dumps(kwargs['body'])
169
170 resp, body_iter = self._http_request(url, method, **kwargs)
171
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -0500172 if 'application/json' in resp.getheader('content-type', ''):
Matthew Treinish72ea4422013-02-07 14:42:49 -0500173 body = ''.join([chunk for chunk in body_iter])
174 try:
175 body = json.loads(body)
176 except ValueError:
177 LOG.error('Could not decode response body as JSON')
178 else:
Mauro S. M. Rodrigues5403b792014-05-06 15:24:54 -0400179 msg = "Only json/application content-type is supported."
180 raise exc.InvalidContentType(msg)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500181
182 return resp, body
183
184 def raw_request(self, method, url, **kwargs):
185 kwargs.setdefault('headers', {})
186 kwargs['headers'].setdefault('Content-Type',
187 'application/octet-stream')
188 if 'body' in kwargs:
189 if (hasattr(kwargs['body'], 'read')
190 and method.lower() in ('post', 'put')):
191 # We use 'Transfer-Encoding: chunked' because
192 # body size may not always be known in advance.
193 kwargs['headers']['Transfer-Encoding'] = 'chunked'
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000194
195 # Decorate the request with auth
196 req_url, kwargs['headers'], kwargs['body'] = \
197 self.auth_provider.auth_request(
198 method=method, url=url, headers=kwargs['headers'],
199 body=kwargs.get('body', None), filters=self.filters)
200 return self._http_request(req_url, method, **kwargs)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500201
202
203class OpenSSLConnectionDelegator(object):
204 """
205 An OpenSSL.SSL.Connection delegator.
206
207 Supplies an additional 'makefile' method which httplib requires
208 and is not present in OpenSSL.SSL.Connection.
209
210 Note: Since it is not possible to inherit from OpenSSL.SSL.Connection
211 a delegator must be used.
212 """
213 def __init__(self, *args, **kwargs):
214 self.connection = OpenSSL.SSL.Connection(*args, **kwargs)
215
216 def __getattr__(self, name):
217 return getattr(self.connection, name)
218
219 def makefile(self, *args, **kwargs):
Matthew Treinishab23e902014-01-27 22:18:15 +0000220 # Ensure the socket is closed when this file is closed
221 kwargs['close'] = True
Matthew Treinish72ea4422013-02-07 14:42:49 -0500222 return socket._fileobject(self.connection, *args, **kwargs)
223
224
225class VerifiedHTTPSConnection(httplib.HTTPSConnection):
226 """
227 Extended HTTPSConnection which uses the OpenSSL library
228 for enhanced SSL support.
229 Note: Much of this functionality can eventually be replaced
230 with native Python 3.3 code.
231 """
232 def __init__(self, host, port=None, key_file=None, cert_file=None,
233 cacert=None, timeout=None, insecure=False,
234 ssl_compression=True):
235 httplib.HTTPSConnection.__init__(self, host, port,
236 key_file=key_file,
237 cert_file=cert_file)
238 self.key_file = key_file
239 self.cert_file = cert_file
240 self.timeout = timeout
241 self.insecure = insecure
242 self.ssl_compression = ssl_compression
243 self.cacert = cacert
244 self.setcontext()
245
246 @staticmethod
247 def host_matches_cert(host, x509):
248 """
249 Verify that the the x509 certificate we have received
250 from 'host' correctly identifies the server we are
251 connecting to, ie that the certificate's Common Name
252 or a Subject Alternative Name matches 'host'.
253 """
254 # First see if we can match the CN
255 if x509.get_subject().commonName == host:
256 return True
257
258 # Also try Subject Alternative Names for a match
259 san_list = None
llg821243b20502014-02-22 10:32:49 +0800260 for i in moves.xrange(x509.get_extension_count()):
Matthew Treinish72ea4422013-02-07 14:42:49 -0500261 ext = x509.get_extension(i)
262 if ext.get_short_name() == 'subjectAltName':
263 san_list = str(ext)
264 for san in ''.join(san_list.split()).split(','):
265 if san == "DNS:%s" % host:
266 return True
267
268 # Server certificate does not match host
269 msg = ('Host "%s" does not match x509 certificate contents: '
270 'CommonName "%s"' % (host, x509.get_subject().commonName))
271 if san_list is not None:
272 msg = msg + ', subjectAltName "%s"' % san_list
273 raise exc.SSLCertificateError(msg)
274
275 def verify_callback(self, connection, x509, errnum,
276 depth, preverify_ok):
277 if x509.has_expired():
278 msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
279 raise exc.SSLCertificateError(msg)
280
281 if depth == 0 and preverify_ok is True:
282 # We verify that the host matches against the last
283 # certificate in the chain
284 return self.host_matches_cert(self.host, x509)
285 else:
286 # Pass through OpenSSL's default result
287 return preverify_ok
288
289 def setcontext(self):
290 """
291 Set up the OpenSSL context.
292 """
293 self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
294
295 if self.ssl_compression is False:
296 self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION
297
298 if self.insecure is not True:
299 self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
300 self.verify_callback)
301 else:
302 self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
303 self.verify_callback)
304
305 if self.cert_file:
306 try:
307 self.context.use_certificate_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200308 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500309 msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
310 raise exc.SSLConfigurationError(msg)
311 if self.key_file is None:
312 # We support having key and cert in same file
313 try:
314 self.context.use_privatekey_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200315 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500316 msg = ('No key file specified and unable to load key '
317 'from "%s" %s' % (self.cert_file, e))
318 raise exc.SSLConfigurationError(msg)
319
320 if self.key_file:
321 try:
322 self.context.use_privatekey_file(self.key_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200323 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500324 msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
325 raise exc.SSLConfigurationError(msg)
326
327 if self.cacert:
328 try:
329 self.context.load_verify_locations(self.cacert)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200330 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500331 msg = 'Unable to load CA from "%s"' % (self.cacert, e)
332 raise exc.SSLConfigurationError(msg)
333 else:
334 self.context.set_default_verify_paths()
335
336 def connect(self):
337 """
338 Connect to an SSL port using the OpenSSL library and apply
339 per-connection parameters.
340 """
341 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
342 if self.timeout is not None:
343 # '0' microseconds
344 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
345 struct.pack('LL', self.timeout, 0))
346 self.sock = OpenSSLConnectionDelegator(self.context, sock)
347 self.sock.connect((self.host, self.port))
348
Matthew Treinishab23e902014-01-27 22:18:15 +0000349 def close(self):
350 if self.sock:
351 # Remove the reference to the socket but don't close it yet.
352 # Response close will close both socket and associated
353 # file. Closing socket too soon will cause response
354 # reads to fail with socket IO error 'Bad file descriptor'.
355 self.sock = None
Matthew Treinish7741cd62014-01-28 16:10:40 +0000356 httplib.HTTPSConnection.close(self)
Matthew Treinishab23e902014-01-27 22:18:15 +0000357
Matthew Treinish72ea4422013-02-07 14:42:49 -0500358
359class ResponseBodyIterator(object):
360 """A class that acts as an iterator over an HTTP response."""
361
362 def __init__(self, resp):
363 self.resp = resp
364
365 def __iter__(self):
366 while True:
367 yield self.next()
368
369 def next(self):
370 chunk = self.resp.read(CHUNKSIZE)
371 if chunk:
372 return chunk
373 else:
374 raise StopIteration()