blob: 831874d1779e5b23418865b056b6902ec2b47e16 [file] [log] [blame]
Matthew Treinish72ea4422013-02-07 14:42:49 -05001# Copyright 2012 OpenStack LLC.
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
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):
221 return socket._fileobject(self.connection, *args, **kwargs)
222
223
224class VerifiedHTTPSConnection(httplib.HTTPSConnection):
225 """
226 Extended HTTPSConnection which uses the OpenSSL library
227 for enhanced SSL support.
228 Note: Much of this functionality can eventually be replaced
229 with native Python 3.3 code.
230 """
231 def __init__(self, host, port=None, key_file=None, cert_file=None,
232 cacert=None, timeout=None, insecure=False,
233 ssl_compression=True):
234 httplib.HTTPSConnection.__init__(self, host, port,
235 key_file=key_file,
236 cert_file=cert_file)
237 self.key_file = key_file
238 self.cert_file = cert_file
239 self.timeout = timeout
240 self.insecure = insecure
241 self.ssl_compression = ssl_compression
242 self.cacert = cacert
243 self.setcontext()
244
245 @staticmethod
246 def host_matches_cert(host, x509):
247 """
248 Verify that the the x509 certificate we have received
249 from 'host' correctly identifies the server we are
250 connecting to, ie that the certificate's Common Name
251 or a Subject Alternative Name matches 'host'.
252 """
253 # First see if we can match the CN
254 if x509.get_subject().commonName == host:
255 return True
256
257 # Also try Subject Alternative Names for a match
258 san_list = None
259 for i in xrange(x509.get_extension_count()):
260 ext = x509.get_extension(i)
261 if ext.get_short_name() == 'subjectAltName':
262 san_list = str(ext)
263 for san in ''.join(san_list.split()).split(','):
264 if san == "DNS:%s" % host:
265 return True
266
267 # Server certificate does not match host
268 msg = ('Host "%s" does not match x509 certificate contents: '
269 'CommonName "%s"' % (host, x509.get_subject().commonName))
270 if san_list is not None:
271 msg = msg + ', subjectAltName "%s"' % san_list
272 raise exc.SSLCertificateError(msg)
273
274 def verify_callback(self, connection, x509, errnum,
275 depth, preverify_ok):
276 if x509.has_expired():
277 msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
278 raise exc.SSLCertificateError(msg)
279
280 if depth == 0 and preverify_ok is True:
281 # We verify that the host matches against the last
282 # certificate in the chain
283 return self.host_matches_cert(self.host, x509)
284 else:
285 # Pass through OpenSSL's default result
286 return preverify_ok
287
288 def setcontext(self):
289 """
290 Set up the OpenSSL context.
291 """
292 self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
293
294 if self.ssl_compression is False:
295 self.context.set_options(0x20000) # SSL_OP_NO_COMPRESSION
296
297 if self.insecure is not True:
298 self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
299 self.verify_callback)
300 else:
301 self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
302 self.verify_callback)
303
304 if self.cert_file:
305 try:
306 self.context.use_certificate_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200307 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500308 msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
309 raise exc.SSLConfigurationError(msg)
310 if self.key_file is None:
311 # We support having key and cert in same file
312 try:
313 self.context.use_privatekey_file(self.cert_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200314 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500315 msg = ('No key file specified and unable to load key '
316 'from "%s" %s' % (self.cert_file, e))
317 raise exc.SSLConfigurationError(msg)
318
319 if self.key_file:
320 try:
321 self.context.use_privatekey_file(self.key_file)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200322 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500323 msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
324 raise exc.SSLConfigurationError(msg)
325
326 if self.cacert:
327 try:
328 self.context.load_verify_locations(self.cacert)
Dirk Mueller1db5db22013-06-23 20:21:32 +0200329 except Exception as e:
Matthew Treinish72ea4422013-02-07 14:42:49 -0500330 msg = 'Unable to load CA from "%s"' % (self.cacert, e)
331 raise exc.SSLConfigurationError(msg)
332 else:
333 self.context.set_default_verify_paths()
334
335 def connect(self):
336 """
337 Connect to an SSL port using the OpenSSL library and apply
338 per-connection parameters.
339 """
340 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
341 if self.timeout is not None:
342 # '0' microseconds
343 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
344 struct.pack('LL', self.timeout, 0))
345 self.sock = OpenSSLConnectionDelegator(self.context, sock)
346 self.sock.connect((self.host, self.port))
347
348
349class ResponseBodyIterator(object):
350 """A class that acts as an iterator over an HTTP response."""
351
352 def __init__(self, resp):
353 self.resp = resp
354
355 def __iter__(self):
356 while True:
357 yield self.next()
358
359 def next(self):
360 chunk = self.resp.read(CHUNKSIZE)
361 if chunk:
362 return chunk
363 else:
364 raise StopIteration()