55769a064577b13d27581eb4533919c14fa7ba15
[escalator.git] / client / escalatorclient / common / https.py
1 # Copyright 2014 Red Hat, Inc
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 import socket
17 import ssl
18 import struct
19
20 import OpenSSL
21 from requests import adapters
22 from requests import compat
23 try:
24     from requests.packages.urllib3 import connectionpool
25 except ImportError:
26     from urllib3 import connectionpool
27
28 from oslo_utils import encodeutils
29 import six
30 # NOTE(jokke): simplified transition to py3, behaves like py2 xrange
31 from six.moves import range
32
33 from escalatorclient.common import utils
34
35 try:
36     from eventlet import patcher
37     # Handle case where we are running in a monkey patched environment
38     if patcher.is_monkey_patched('socket'):
39         from eventlet.green.httplib import HTTPSConnection
40         from eventlet.green.OpenSSL.SSL import GreenConnection as Connection
41         from eventlet.greenio import GreenSocket
42         # TODO(mclaren): A getsockopt workaround: see 'getsockopt' doc string
43         GreenSocket.getsockopt = utils.getsockopt
44     else:
45         raise ImportError
46 except ImportError:
47     try:
48         from httplib import HTTPSConnection
49     except ImportError:
50         from http.client import HTTPSConnection
51     from OpenSSL.SSL import Connection as Connection
52
53
54 from escalatorclient import exc
55
56
57 def verify_callback(host=None):
58     """
59     We use a partial around the 'real' verify_callback function
60     so that we can stash the host value without holding a
61     reference on the VerifiedHTTPSConnection.
62     """
63     def wrapper(connection, x509, errnum,
64                 depth, preverify_ok, host=host):
65         return do_verify_callback(connection, x509, errnum,
66                                   depth, preverify_ok, host=host)
67     return wrapper
68
69
70 def do_verify_callback(connection, x509, errnum,
71                        depth, preverify_ok, host=None):
72     """
73     Verify the server's SSL certificate.
74
75     This is a standalone function rather than a method to avoid
76     issues around closing sockets if a reference is held on
77     a VerifiedHTTPSConnection by the callback function.
78     """
79     if x509.has_expired():
80         msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
81         raise exc.SSLCertificateError(msg)
82
83     if depth == 0 and preverify_ok:
84         # We verify that the host matches against the last
85         # certificate in the chain
86         return host_matches_cert(host, x509)
87     else:
88         # Pass through OpenSSL's default result
89         return preverify_ok
90
91
92 def host_matches_cert(host, x509):
93     """
94     Verify that the x509 certificate we have received
95     from 'host' correctly identifies the server we are
96     connecting to, ie that the certificate's Common Name
97     or a Subject Alternative Name matches 'host'.
98     """
99     def check_match(name):
100         # Directly match the name
101         if name == host:
102             return True
103
104         # Support single wildcard matching
105         if name.startswith('*.') and host.find('.') > 0:
106             if name[2:] == host.split('.', 1)[1]:
107                 return True
108
109     common_name = x509.get_subject().commonName
110
111     # First see if we can match the CN
112     if check_match(common_name):
113         return True
114         # Also try Subject Alternative Names for a match
115     san_list = None
116     for i in range(x509.get_extension_count()):
117         ext = x509.get_extension(i)
118         if ext.get_short_name() == b'subjectAltName':
119             san_list = str(ext)
120             for san in ''.join(san_list.split()).split(','):
121                 if san.startswith('DNS:'):
122                     if check_match(san.split(':', 1)[1]):
123                         return True
124
125     # Server certificate does not match host
126     msg = ('Host "%s" does not match x509 certificate contents: '
127            'CommonName "%s"' % (host, common_name))
128     if san_list is not None:
129         msg = msg + ', subjectAltName "%s"' % san_list
130     raise exc.SSLCertificateError(msg)
131
132
133 def to_bytes(s):
134     if isinstance(s, six.string_types):
135         return six.b(s)
136     else:
137         return s
138
139
140 class HTTPSAdapter(adapters.HTTPAdapter):
141     """
142     This adapter will be used just when
143     ssl compression should be disabled.
144
145     The init method overwrites the default
146     https pool by setting escalatorclient's
147     one.
148     """
149
150     def request_url(self, request, proxies):
151         # NOTE(flaper87): Make sure the url is encoded, otherwise
152         # python's standard httplib will fail with a TypeError.
153         url = super(HTTPSAdapter, self).request_url(request, proxies)
154         return encodeutils.safe_encode(url)
155
156     def _create_escalator_httpsconnectionpool(self, url):
157         kw = self.poolmanager.connection_kw
158         # Parse the url to get the scheme, host, and port
159         parsed = compat.urlparse(url)
160         # If there is no port specified, we should use the standard HTTPS port
161         port = parsed.port or 443
162         pool = HTTPSConnectionPool(parsed.host, port, **kw)
163
164         with self.poolmanager.pools.lock:
165             self.poolmanager.pools[(parsed.scheme, parsed.host, port)] = pool
166
167         return pool
168
169     def get_connection(self, url, proxies=None):
170         try:
171             return super(HTTPSAdapter, self).get_connection(url, proxies)
172         except KeyError:
173             # NOTE(sigamvirus24): This works around modifying a module global
174             # which fixes bug #1396550
175             # The scheme is most likely escalator+https but check anyway
176             if not url.startswith('escalator+https://'):
177                 raise
178
179             return self._create_escalator_httpsconnectionpool(url)
180
181     def cert_verify(self, conn, url, verify, cert):
182         super(HTTPSAdapter, self).cert_verify(conn, url, verify, cert)
183         conn.ca_certs = verify[0]
184         conn.insecure = verify[1]
185
186
187 class HTTPSConnectionPool(connectionpool.HTTPSConnectionPool):
188     """
189     HTTPSConnectionPool will be instantiated when a new
190     connection is requested to the HTTPSAdapter.This
191     implementation overwrites the _new_conn method and
192     returns an instances of escalatorclient's VerifiedHTTPSConnection
193     which handles no compression.
194
195     ssl_compression is hard-coded to False because this will
196     be used just when the user sets --no-ssl-compression.
197     """
198
199     scheme = 'escalator+https'
200
201     def _new_conn(self):
202         self.num_connections += 1
203         return VerifiedHTTPSConnection(host=self.host,
204                                        port=self.port,
205                                        key_file=self.key_file,
206                                        cert_file=self.cert_file,
207                                        cacert=self.ca_certs,
208                                        insecure=self.insecure,
209                                        ssl_compression=False)
210
211
212 class OpenSSLConnectionDelegator(object):
213     """
214     An OpenSSL.SSL.Connection delegator.
215
216     Supplies an additional 'makefile' method which httplib requires
217     and is not present in OpenSSL.SSL.Connection.
218
219     Note: Since it is not possible to inherit from OpenSSL.SSL.Connection
220     a delegator must be used.
221     """
222     def __init__(self, *args, **kwargs):
223         self.connection = Connection(*args, **kwargs)
224
225     def __getattr__(self, name):
226         return getattr(self.connection, name)
227
228     def makefile(self, *args, **kwargs):
229         return socket._fileobject(self.connection, *args, **kwargs)
230
231
232 class VerifiedHTTPSConnection(HTTPSConnection):
233     """
234     Extended HTTPSConnection which uses the OpenSSL library
235     for enhanced SSL support.
236     Note: Much of this functionality can eventually be replaced
237           with native Python 3.3 code.
238     """
239     # Restrict the set of client supported cipher suites
240     CIPHERS = 'ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:'\
241               'eCDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:'\
242               'RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS'
243
244     def __init__(self, host, port=None, key_file=None, cert_file=None,
245                  cacert=None, timeout=None, insecure=False,
246                  ssl_compression=True):
247         # List of exceptions reported by Python3 instead of
248         # SSLConfigurationError
249         if six.PY3:
250             excp_lst = (TypeError, IOError, ssl.SSLError)
251             # https.py:250:36: F821 undefined name 'FileNotFoundError'
252         else:
253             # NOTE(jamespage)
254             # Accomodate changes in behaviour for pep-0467, introduced
255             # in python 2.7.9.
256             # https://github.com/python/peps/blob/master/pep-0476.txt
257             excp_lst = (TypeError, IOError, ssl.SSLError)
258         try:
259             HTTPSConnection.__init__(self, host, port,
260                                      key_file=key_file,
261                                      cert_file=cert_file)
262             self.key_file = key_file
263             self.cert_file = cert_file
264             self.timeout = timeout
265             self.insecure = insecure
266             # NOTE(flaper87): `is_verified` is needed for
267             # requests' urllib3. If insecure is True then
268             # the request is not `verified`, hence `not insecure`
269             self.is_verified = not insecure
270             self.ssl_compression = ssl_compression
271             self.cacert = None if cacert is None else str(cacert)
272             self.set_context()
273             # ssl exceptions are reported in various form in Python 3
274             # so to be compatible, we report the same kind as under
275             # Python2
276         except excp_lst as e:
277             raise exc.SSLConfigurationError(str(e))
278
279     def set_context(self):
280         """
281         Set up the OpenSSL context.
282         """
283         self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
284         self.context.set_cipher_list(self.CIPHERS)
285
286         if self.ssl_compression is False:
287             self.context.set_options(0x20000)  # SSL_OP_NO_COMPRESSION
288
289         if self.insecure is not True:
290             self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
291                                     verify_callback(host=self.host))
292         else:
293             self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
294                                     lambda *args: True)
295
296         if self.cert_file:
297             try:
298                 self.context.use_certificate_file(self.cert_file)
299             except Exception as e:
300                 msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
301                 raise exc.SSLConfigurationError(msg)
302             if self.key_file is None:
303                 # We support having key and cert in same file
304                 try:
305                     self.context.use_privatekey_file(self.cert_file)
306                 except Exception as e:
307                     msg = ('No key file specified and unable to load key '
308                            'from "%s" %s' % (self.cert_file, e))
309                     raise exc.SSLConfigurationError(msg)
310
311         if self.key_file:
312             try:
313                 self.context.use_privatekey_file(self.key_file)
314             except Exception as e:
315                 msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
316                 raise exc.SSLConfigurationError(msg)
317
318         if self.cacert:
319             try:
320                 self.context.load_verify_locations(to_bytes(self.cacert))
321             except Exception as e:
322                 msg = 'Unable to load CA from "%s" %s' % (self.cacert, e)
323                 raise exc.SSLConfigurationError(msg)
324         else:
325             self.context.set_default_verify_paths()
326
327     def connect(self):
328         """
329         Connect to an SSL port using the OpenSSL library and apply
330         per-connection parameters.
331         """
332         result = socket.getaddrinfo(self.host, self.port, 0,
333                                     socket.SOCK_STREAM)
334         if result:
335             socket_family = result[0][0]
336             if socket_family == socket.AF_INET6:
337                 sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
338             else:
339                 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
340         else:
341             # If due to some reason the address lookup fails - we still connect
342             # to IPv4 socket. This retains the older behavior.
343             sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
344         if self.timeout is not None:
345             # '0' microseconds
346             sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
347                             struct.pack('LL', self.timeout, 0))
348         self.sock = OpenSSLConnectionDelegator(self.context, sock)
349         self.sock.connect((self.host, self.port))