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