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