blob: 4ddaf175406feafdf1f5e0e07e88ddb55eac0225 [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 logging
23import posixpath
Matthew Treinish6900ba12013-02-19 16:38:01 -050024import re
Matthew Treinish72ea4422013-02-07 14:42:49 -050025import socket
26import StringIO
27import struct
28import urlparse
29
Matthew Treinish72ea4422013-02-07 14:42:49 -050030
31# Python 2.5 compat fix
32if not hasattr(urlparse, 'parse_qsl'):
33 import cgi
34 urlparse.parse_qsl = cgi.parse_qsl
35
36import OpenSSL
37
38from tempest import exceptions as exc
39
40
41LOG = logging.getLogger(__name__)
42USER_AGENT = 'tempest'
43CHUNKSIZE = 1024 * 64 # 64kB
Matthew Treinish6900ba12013-02-19 16:38:01 -050044TOKEN_CHARS_RE = re.compile('^[-A-Za-z0-9+/=]*$')
Matthew Treinish72ea4422013-02-07 14:42:49 -050045
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
Matthew Treinish72ea4422013-02-07 14:42:49 -050095 def _http_request(self, url, method, **kwargs):
Attila Fazekasb2902af2013-02-16 16:22:44 +010096 """Send an http request with the specified characteristics.
Matthew Treinish72ea4422013-02-07 14:42:49 -050097
98 Wrapper around httplib.HTTP(S)Connection.request to handle tasks such
99 as setting headers and error handling.
100 """
101 # Copy the kwargs so we can reuse the original in case of redirects
102 kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {}))
103 kwargs['headers'].setdefault('User-Agent', USER_AGENT)
104 if self.auth_token:
105 kwargs['headers'].setdefault('X-Auth-Token', self.auth_token)
106
Matthew Treinish6900ba12013-02-19 16:38:01 -0500107 self._log_request(method, url, kwargs['headers'])
108
Matthew Treinish72ea4422013-02-07 14:42:49 -0500109 conn = self.get_connection()
110
111 try:
112 conn_url = posixpath.normpath('%s/%s' % (self.endpoint_path, url))
113 if kwargs['headers'].get('Transfer-Encoding') == 'chunked':
114 conn.putrequest(method, conn_url)
115 for header, value in kwargs['headers'].items():
116 conn.putheader(header, value)
117 conn.endheaders()
118 chunk = kwargs['body'].read(CHUNKSIZE)
119 # Chunk it, baby...
120 while chunk:
121 conn.send('%x\r\n%s\r\n' % (len(chunk), chunk))
122 chunk = kwargs['body'].read(CHUNKSIZE)
123 conn.send('0\r\n\r\n')
124 else:
125 conn.request(method, conn_url, **kwargs)
126 resp = conn.getresponse()
127 except socket.gaierror as e:
128 message = "Error finding address for %(url)s: %(e)s" % locals()
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:
131 endpoint = self.endpoint
132 message = "Error communicating with %(endpoint)s %(e)s" % locals()
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)
307 except Exception, e:
308 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)
314 except Exception, e:
315 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)
322 except Exception, e:
323 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)
329 except Exception, e:
330 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()