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