blob: 92ed8f1a49e23e4f7d9750eeec39132afde94f81 [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
Matthew Treinish72ea4422013-02-07 14:42:49 -050024import socket
25import StringIO
26import struct
27import urlparse
28
Matthew Treinish96e9e882014-06-09 18:37:19 -040029
30import OpenSSL
31from six import moves
32
Matthew Treinish72ea4422013-02-07 14:42:49 -050033from tempest import exceptions as exc
Matthew Treinishf4a9b0f2013-07-26 16:58:26 -040034from tempest.openstack.common import log as logging
Matthew Treinish72ea4422013-02-07 14:42:49 -050035
36LOG = logging.getLogger(__name__)
37USER_AGENT = 'tempest'
38CHUNKSIZE = 1024 * 64 # 64kB
Matthew Treinish6900ba12013-02-19 16:38:01 -050039TOKEN_CHARS_RE = re.compile('^[-A-Za-z0-9+/=]*$')
Matthew Treinish72ea4422013-02-07 14:42:49 -050040
41
42class HTTPClient(object):
43
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000044 def __init__(self, auth_provider, filters, **kwargs):
45 self.auth_provider = auth_provider
46 self.filters = filters
47 self.endpoint = auth_provider.base_url(filters)
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -050048 endpoint_parts = urlparse.urlparse(self.endpoint)
Matthew Treinish72ea4422013-02-07 14:42:49 -050049 self.endpoint_scheme = endpoint_parts.scheme
50 self.endpoint_hostname = endpoint_parts.hostname
51 self.endpoint_port = endpoint_parts.port
52 self.endpoint_path = endpoint_parts.path
53
54 self.connection_class = self.get_connection_class(self.endpoint_scheme)
55 self.connection_kwargs = self.get_connection_kwargs(
56 self.endpoint_scheme, **kwargs)
57
Matthew Treinish72ea4422013-02-07 14:42:49 -050058 @staticmethod
Matthew Treinish72ea4422013-02-07 14:42:49 -050059 def get_connection_class(scheme):
60 if scheme == 'https':
61 return VerifiedHTTPSConnection
62 else:
63 return httplib.HTTPConnection
64
65 @staticmethod
66 def get_connection_kwargs(scheme, **kwargs):
67 _kwargs = {'timeout': float(kwargs.get('timeout', 600))}
68
69 if scheme == 'https':
Joseph Lanouxc9c06be2015-01-21 09:03:30 +000070 _kwargs['ca_certs'] = kwargs.get('ca_certs', None)
Matthew Treinish72ea4422013-02-07 14:42:49 -050071 _kwargs['cert_file'] = kwargs.get('cert_file', None)
72 _kwargs['key_file'] = kwargs.get('key_file', None)
73 _kwargs['insecure'] = kwargs.get('insecure', False)
74 _kwargs['ssl_compression'] = kwargs.get('ssl_compression', True)
75
76 return _kwargs
77
78 def get_connection(self):
79 _class = self.connection_class
80 try:
81 return _class(self.endpoint_hostname, self.endpoint_port,
82 **self.connection_kwargs)
83 except httplib.InvalidURL:
84 raise exc.EndpointNotFound
85
Matthew Treinish72ea4422013-02-07 14:42:49 -050086 def _http_request(self, url, method, **kwargs):
Attila Fazekasb2902af2013-02-16 16:22:44 +010087 """Send an http request with the specified characteristics.
Matthew Treinish72ea4422013-02-07 14:42:49 -050088
89 Wrapper around httplib.HTTP(S)Connection.request to handle tasks such
90 as setting headers and error handling.
91 """
92 # Copy the kwargs so we can reuse the original in case of redirects
93 kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {}))
94 kwargs['headers'].setdefault('User-Agent', USER_AGENT)
Matthew Treinish72ea4422013-02-07 14:42:49 -050095
Matthew Treinish6900ba12013-02-19 16:38:01 -050096 self._log_request(method, url, kwargs['headers'])
97
Matthew Treinish72ea4422013-02-07 14:42:49 -050098 conn = self.get_connection()
99
100 try:
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -0500101 url_parts = urlparse.urlparse(url)
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000102 conn_url = posixpath.normpath(url_parts.path)
103 LOG.debug('Actual Path: {path}'.format(path=conn_url))
Matthew Treinish72ea4422013-02-07 14:42:49 -0500104 if kwargs['headers'].get('Transfer-Encoding') == 'chunked':
105 conn.putrequest(method, conn_url)
106 for header, value in kwargs['headers'].items():
107 conn.putheader(header, value)
108 conn.endheaders()
109 chunk = kwargs['body'].read(CHUNKSIZE)
110 # Chunk it, baby...
111 while chunk:
112 conn.send('%x\r\n%s\r\n' % (len(chunk), chunk))
113 chunk = kwargs['body'].read(CHUNKSIZE)
114 conn.send('0\r\n\r\n')
115 else:
116 conn.request(method, conn_url, **kwargs)
117 resp = conn.getresponse()
118 except socket.gaierror as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400119 message = ("Error finding address for %(url)s: %(e)s" %
120 {'url': url, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100121 raise exc.EndpointNotFound(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500122 except (socket.error, socket.timeout) as e:
Sean Dague43cd9052013-07-19 12:20:04 -0400123 message = ("Error communicating with %(endpoint)s %(e)s" %
124 {'endpoint': self.endpoint, 'e': e})
Attila Fazekasc7920282013-03-01 13:04:54 +0100125 raise exc.TimeoutException(message)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500126
127 body_iter = ResponseBodyIterator(resp)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500128 # Read body into string if it isn't obviously image data
129 if resp.getheader('content-type', None) != 'application/octet-stream':
Monty Taylorb2ca5ca2013-04-28 18:00:21 -0700130 body_str = ''.join([body_chunk for body_chunk in body_iter])
Matthew Treinish72ea4422013-02-07 14:42:49 -0500131 body_iter = StringIO.StringIO(body_str)
Matthew Treinish6900ba12013-02-19 16:38:01 -0500132 self._log_response(resp, None)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500133 else:
Matthew Treinish6900ba12013-02-19 16:38:01 -0500134 self._log_response(resp, body_iter)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500135
136 return resp, body_iter
137
Matthew Treinish6900ba12013-02-19 16:38:01 -0500138 def _log_request(self, method, url, headers):
139 LOG.info('Request: ' + method + ' ' + url)
140 if headers:
141 headers_out = headers
142 if 'X-Auth-Token' in headers and headers['X-Auth-Token']:
143 token = headers['X-Auth-Token']
144 if len(token) > 64 and TOKEN_CHARS_RE.match(token):
145 headers_out = headers.copy()
146 headers_out['X-Auth-Token'] = "<Token omitted>"
147 LOG.info('Request Headers: ' + str(headers_out))
148
149 def _log_response(self, resp, body):
150 status = str(resp.status)
151 LOG.info("Response Status: " + status)
152 if resp.getheaders():
153 LOG.info('Response Headers: ' + str(resp.getheaders()))
154 if body:
155 str_body = str(body)
156 length = len(body)
157 LOG.info('Response Body: ' + str_body[:2048])
158 if length >= 2048:
159 self.LOG.debug("Large body (%d) md5 summary: %s", length,
160 hashlib.md5(str_body).hexdigest())
161
Matthew Treinish72ea4422013-02-07 14:42:49 -0500162 def json_request(self, method, url, **kwargs):
163 kwargs.setdefault('headers', {})
164 kwargs['headers'].setdefault('Content-Type', 'application/json')
Mauro S. M. Rodrigues5403b792014-05-06 15:24:54 -0400165 if kwargs['headers']['Content-Type'] != 'application/json':
166 msg = "Only application/json content-type is supported."
167 raise exc.InvalidContentType(msg)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500168
169 if 'body' in kwargs:
170 kwargs['body'] = json.dumps(kwargs['body'])
171
172 resp, body_iter = self._http_request(url, method, **kwargs)
173
Mauro S. M. Rodrigues568638f2014-03-05 09:09:38 -0500174 if 'application/json' in resp.getheader('content-type', ''):
Matthew Treinish72ea4422013-02-07 14:42:49 -0500175 body = ''.join([chunk for chunk in body_iter])
176 try:
177 body = json.loads(body)
178 except ValueError:
179 LOG.error('Could not decode response body as JSON')
180 else:
Mauro S. M. Rodrigues5403b792014-05-06 15:24:54 -0400181 msg = "Only json/application content-type is supported."
182 raise exc.InvalidContentType(msg)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500183
184 return resp, body
185
186 def raw_request(self, method, url, **kwargs):
187 kwargs.setdefault('headers', {})
188 kwargs['headers'].setdefault('Content-Type',
189 'application/octet-stream')
190 if 'body' in kwargs:
191 if (hasattr(kwargs['body'], 'read')
192 and method.lower() in ('post', 'put')):
193 # We use 'Transfer-Encoding: chunked' because
194 # body size may not always be known in advance.
195 kwargs['headers']['Transfer-Encoding'] = 'chunked'
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000196
197 # Decorate the request with auth
198 req_url, kwargs['headers'], kwargs['body'] = \
199 self.auth_provider.auth_request(
200 method=method, url=url, headers=kwargs['headers'],
201 body=kwargs.get('body', None), filters=self.filters)
202 return self._http_request(req_url, method, **kwargs)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500203
204
205class OpenSSLConnectionDelegator(object):
206 """
207 An OpenSSL.SSL.Connection delegator.
208
209 Supplies an additional 'makefile' method which httplib requires
210 and is not present in OpenSSL.SSL.Connection.
211
212 Note: Since it is not possible to inherit from OpenSSL.SSL.Connection
213 a delegator must be used.
214 """
215 def __init__(self, *args, **kwargs):
216 self.connection = OpenSSL.SSL.Connection(*args, **kwargs)
217
218 def __getattr__(self, name):
219 return getattr(self.connection, name)
220
221 def makefile(self, *args, **kwargs):
Matthew Treinishab23e902014-01-27 22:18:15 +0000222 # Ensure the socket is closed when this file is closed
223 kwargs['close'] = True
Matthew Treinish72ea4422013-02-07 14:42:49 -0500224 return socket._fileobject(self.connection, *args, **kwargs)
225
226
227class VerifiedHTTPSConnection(httplib.HTTPSConnection):
228 """
229 Extended HTTPSConnection which uses the OpenSSL library
230 for enhanced SSL support.
231 Note: Much of this functionality can eventually be replaced
232 with native Python 3.3 code.
233 """
234 def __init__(self, host, port=None, key_file=None, cert_file=None,
Joseph Lanouxc9c06be2015-01-21 09:03:30 +0000235 ca_certs=None, timeout=None, insecure=False,
Matthew Treinish72ea4422013-02-07 14:42:49 -0500236 ssl_compression=True):
237 httplib.HTTPSConnection.__init__(self, host, port,
238 key_file=key_file,
239 cert_file=cert_file)
240 self.key_file = key_file
241 self.cert_file = cert_file
242 self.timeout = timeout
243 self.insecure = insecure
244 self.ssl_compression = ssl_compression
Joseph Lanouxc9c06be2015-01-21 09:03:30 +0000245 self.ca_certs = ca_certs
Matthew Treinish72ea4422013-02-07 14:42:49 -0500246 self.setcontext()
247
248 @staticmethod
249 def host_matches_cert(host, x509):
250 """
251 Verify that the the x509 certificate we have received
252 from 'host' correctly identifies the server we are
253 connecting to, ie that the certificate's Common Name
254 or a Subject Alternative Name matches 'host'.
255 """
256 # First see if we can match the CN
257 if x509.get_subject().commonName == host:
258 return True
259
260 # Also try Subject Alternative Names for a match
261 san_list = None
llg821243b20502014-02-22 10:32:49 +0800262 for i in moves.xrange(x509.get_extension_count()):
Matthew Treinish72ea4422013-02-07 14:42:49 -0500263 ext = x509.get_extension(i)
264 if ext.get_short_name() == 'subjectAltName':
265 san_list = str(ext)
266 for san in ''.join(san_list.split()).split(','):
267 if san == "DNS:%s" % host:
268 return True
269
270 # Server certificate does not match host
271 msg = ('Host "%s" does not match x509 certificate contents: '
272 'CommonName "%s"' % (host, x509.get_subject().commonName))
273 if san_list is not None:
274 msg = msg + ', subjectAltName "%s"' % san_list
275 raise exc.SSLCertificateError(msg)
276
277 def verify_callback(self, connection, x509, errnum,
278 depth, preverify_ok):
279 if x509.has_expired():
280 msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
281 raise exc.SSLCertificateError(msg)
282
283 if depth == 0 and preverify_ok is True:
284 # We verify that the host matches against the last
285 # certificate in the chain
286 return self.host_matches_cert(self.host, x509)
287 else:
288 # Pass through OpenSSL's default result
289 return preverify_ok
290
291 def setcontext(self):
292 """
293 Set up the OpenSSL context.
294 """
295 self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
296
297 if self.ssl_compression is False:
298 self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION
299
300 if self.insecure is not True:
301 self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
302 self.verify_callback)
303 else:
304 self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
305 self.verify_callback)
306
307 if self.cert_file:
308 try:
309 self.context.use_certificate_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200310 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500311 msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
312 raise exc.SSLConfigurationError(msg)
313 if self.key_file is None:
314 # We support having key and cert in same file
315 try:
316 self.context.use_privatekey_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200317 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500318 msg = ('No key file specified and unable to load key '
319 'from "%s" %s' % (self.cert_file, e))
320 raise exc.SSLConfigurationError(msg)
321
322 if self.key_file:
323 try:
324 self.context.use_privatekey_file(self.key_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200325 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500326 msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
327 raise exc.SSLConfigurationError(msg)
328
Joseph Lanouxc9c06be2015-01-21 09:03:30 +0000329 if self.ca_certs:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500330 try:
Joseph Lanouxc9c06be2015-01-21 09:03:30 +0000331 self.context.load_verify_locations(self.ca_certs)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200332 except Exception as e:
Joseph Lanouxc9c06be2015-01-21 09:03:30 +0000333 msg = 'Unable to load CA from "%s"' % (self.ca_certs, e)
Matthew Treinish72ea4422013-02-07 14:42:49 -0500334 raise exc.SSLConfigurationError(msg)
335 else:
336 self.context.set_default_verify_paths()
337
338 def connect(self):
339 """
340 Connect to an SSL port using the OpenSSL library and apply
341 per-connection parameters.
342 """
343 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
344 if self.timeout is not None:
345 # '0' microseconds
346 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
347 struct.pack('LL', self.timeout, 0))
348 self.sock = OpenSSLConnectionDelegator(self.context, sock)
349 self.sock.connect((self.host, self.port))
350
Matthew Treinishab23e902014-01-27 22:18:15 +0000351 def close(self):
352 if self.sock:
353 # Remove the reference to the socket but don't close it yet.
354 # Response close will close both socket and associated
355 # file. Closing socket too soon will cause response
356 # reads to fail with socket IO error 'Bad file descriptor'.
357 self.sock = None
Matthew Treinish7741cd62014-01-28 16:10:40 +0000358 httplib.HTTPSConnection.close(self)
Matthew Treinishab23e902014-01-27 22:18:15 +0000359
Matthew Treinish72ea4422013-02-07 14:42:49 -0500360
361class ResponseBodyIterator(object):
362 """A class that acts as an iterator over an HTTP response."""
363
364 def __init__(self, resp):
365 self.resp = resp
366
367 def __iter__(self):
368 while True:
369 yield self.next()
370
371 def next(self):
372 chunk = self.resp.read(CHUNKSIZE)
373 if chunk:
374 return chunk
375 else:
376 raise StopIteration()