add escalator cli framework
[escalator.git] / client / escalatorclient / common / https.py
diff --git a/client/escalatorclient/common/https.py b/client/escalatorclient/common/https.py
new file mode 100644 (file)
index 0000000..55769a0
--- /dev/null
@@ -0,0 +1,349 @@
+# Copyright 2014 Red Hat, Inc
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import socket
+import ssl
+import struct
+
+import OpenSSL
+from requests import adapters
+from requests import compat
+try:
+    from requests.packages.urllib3 import connectionpool
+except ImportError:
+    from urllib3 import connectionpool
+
+from oslo_utils import encodeutils
+import six
+# NOTE(jokke): simplified transition to py3, behaves like py2 xrange
+from six.moves import range
+
+from escalatorclient.common import utils
+
+try:
+    from eventlet import patcher
+    # Handle case where we are running in a monkey patched environment
+    if patcher.is_monkey_patched('socket'):
+        from eventlet.green.httplib import HTTPSConnection
+        from eventlet.green.OpenSSL.SSL import GreenConnection as Connection
+        from eventlet.greenio import GreenSocket
+        # TODO(mclaren): A getsockopt workaround: see 'getsockopt' doc string
+        GreenSocket.getsockopt = utils.getsockopt
+    else:
+        raise ImportError
+except ImportError:
+    try:
+        from httplib import HTTPSConnection
+    except ImportError:
+        from http.client import HTTPSConnection
+    from OpenSSL.SSL import Connection as Connection
+
+
+from escalatorclient import exc
+
+
+def verify_callback(host=None):
+    """
+    We use a partial around the 'real' verify_callback function
+    so that we can stash the host value without holding a
+    reference on the VerifiedHTTPSConnection.
+    """
+    def wrapper(connection, x509, errnum,
+                depth, preverify_ok, host=host):
+        return do_verify_callback(connection, x509, errnum,
+                                  depth, preverify_ok, host=host)
+    return wrapper
+
+
+def do_verify_callback(connection, x509, errnum,
+                       depth, preverify_ok, host=None):
+    """
+    Verify the server's SSL certificate.
+
+    This is a standalone function rather than a method to avoid
+    issues around closing sockets if a reference is held on
+    a VerifiedHTTPSConnection by the callback function.
+    """
+    if x509.has_expired():
+        msg = "SSL Certificate expired on '%s'" % x509.get_notAfter()
+        raise exc.SSLCertificateError(msg)
+
+    if depth == 0 and preverify_ok:
+        # We verify that the host matches against the last
+        # certificate in the chain
+        return host_matches_cert(host, x509)
+    else:
+        # Pass through OpenSSL's default result
+        return preverify_ok
+
+
+def host_matches_cert(host, x509):
+    """
+    Verify that the x509 certificate we have received
+    from 'host' correctly identifies the server we are
+    connecting to, ie that the certificate's Common Name
+    or a Subject Alternative Name matches 'host'.
+    """
+    def check_match(name):
+        # Directly match the name
+        if name == host:
+            return True
+
+        # Support single wildcard matching
+        if name.startswith('*.') and host.find('.') > 0:
+            if name[2:] == host.split('.', 1)[1]:
+                return True
+
+    common_name = x509.get_subject().commonName
+
+    # First see if we can match the CN
+    if check_match(common_name):
+        return True
+        # Also try Subject Alternative Names for a match
+    san_list = None
+    for i in range(x509.get_extension_count()):
+        ext = x509.get_extension(i)
+        if ext.get_short_name() == b'subjectAltName':
+            san_list = str(ext)
+            for san in ''.join(san_list.split()).split(','):
+                if san.startswith('DNS:'):
+                    if check_match(san.split(':', 1)[1]):
+                        return True
+
+    # Server certificate does not match host
+    msg = ('Host "%s" does not match x509 certificate contents: '
+           'CommonName "%s"' % (host, common_name))
+    if san_list is not None:
+        msg = msg + ', subjectAltName "%s"' % san_list
+    raise exc.SSLCertificateError(msg)
+
+
+def to_bytes(s):
+    if isinstance(s, six.string_types):
+        return six.b(s)
+    else:
+        return s
+
+
+class HTTPSAdapter(adapters.HTTPAdapter):
+    """
+    This adapter will be used just when
+    ssl compression should be disabled.
+
+    The init method overwrites the default
+    https pool by setting escalatorclient's
+    one.
+    """
+
+    def request_url(self, request, proxies):
+        # NOTE(flaper87): Make sure the url is encoded, otherwise
+        # python's standard httplib will fail with a TypeError.
+        url = super(HTTPSAdapter, self).request_url(request, proxies)
+        return encodeutils.safe_encode(url)
+
+    def _create_escalator_httpsconnectionpool(self, url):
+        kw = self.poolmanager.connection_kw
+        # Parse the url to get the scheme, host, and port
+        parsed = compat.urlparse(url)
+        # If there is no port specified, we should use the standard HTTPS port
+        port = parsed.port or 443
+        pool = HTTPSConnectionPool(parsed.host, port, **kw)
+
+        with self.poolmanager.pools.lock:
+            self.poolmanager.pools[(parsed.scheme, parsed.host, port)] = pool
+
+        return pool
+
+    def get_connection(self, url, proxies=None):
+        try:
+            return super(HTTPSAdapter, self).get_connection(url, proxies)
+        except KeyError:
+            # NOTE(sigamvirus24): This works around modifying a module global
+            # which fixes bug #1396550
+            # The scheme is most likely escalator+https but check anyway
+            if not url.startswith('escalator+https://'):
+                raise
+
+            return self._create_escalator_httpsconnectionpool(url)
+
+    def cert_verify(self, conn, url, verify, cert):
+        super(HTTPSAdapter, self).cert_verify(conn, url, verify, cert)
+        conn.ca_certs = verify[0]
+        conn.insecure = verify[1]
+
+
+class HTTPSConnectionPool(connectionpool.HTTPSConnectionPool):
+    """
+    HTTPSConnectionPool will be instantiated when a new
+    connection is requested to the HTTPSAdapter.This
+    implementation overwrites the _new_conn method and
+    returns an instances of escalatorclient's VerifiedHTTPSConnection
+    which handles no compression.
+
+    ssl_compression is hard-coded to False because this will
+    be used just when the user sets --no-ssl-compression.
+    """
+
+    scheme = 'escalator+https'
+
+    def _new_conn(self):
+        self.num_connections += 1
+        return VerifiedHTTPSConnection(host=self.host,
+                                       port=self.port,
+                                       key_file=self.key_file,
+                                       cert_file=self.cert_file,
+                                       cacert=self.ca_certs,
+                                       insecure=self.insecure,
+                                       ssl_compression=False)
+
+
+class OpenSSLConnectionDelegator(object):
+    """
+    An OpenSSL.SSL.Connection delegator.
+
+    Supplies an additional 'makefile' method which httplib requires
+    and is not present in OpenSSL.SSL.Connection.
+
+    Note: Since it is not possible to inherit from OpenSSL.SSL.Connection
+    a delegator must be used.
+    """
+    def __init__(self, *args, **kwargs):
+        self.connection = Connection(*args, **kwargs)
+
+    def __getattr__(self, name):
+        return getattr(self.connection, name)
+
+    def makefile(self, *args, **kwargs):
+        return socket._fileobject(self.connection, *args, **kwargs)
+
+
+class VerifiedHTTPSConnection(HTTPSConnection):
+    """
+    Extended HTTPSConnection which uses the OpenSSL library
+    for enhanced SSL support.
+    Note: Much of this functionality can eventually be replaced
+          with native Python 3.3 code.
+    """
+    # Restrict the set of client supported cipher suites
+    CIPHERS = 'ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:'\
+              'eCDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:'\
+              'RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS'
+
+    def __init__(self, host, port=None, key_file=None, cert_file=None,
+                 cacert=None, timeout=None, insecure=False,
+                 ssl_compression=True):
+        # List of exceptions reported by Python3 instead of
+        # SSLConfigurationError
+        if six.PY3:
+            excp_lst = (TypeError, IOError, ssl.SSLError)
+            # https.py:250:36: F821 undefined name 'FileNotFoundError'
+        else:
+            # NOTE(jamespage)
+            # Accomodate changes in behaviour for pep-0467, introduced
+            # in python 2.7.9.
+            # https://github.com/python/peps/blob/master/pep-0476.txt
+            excp_lst = (TypeError, IOError, ssl.SSLError)
+        try:
+            HTTPSConnection.__init__(self, host, port,
+                                     key_file=key_file,
+                                     cert_file=cert_file)
+            self.key_file = key_file
+            self.cert_file = cert_file
+            self.timeout = timeout
+            self.insecure = insecure
+            # NOTE(flaper87): `is_verified` is needed for
+            # requests' urllib3. If insecure is True then
+            # the request is not `verified`, hence `not insecure`
+            self.is_verified = not insecure
+            self.ssl_compression = ssl_compression
+            self.cacert = None if cacert is None else str(cacert)
+            self.set_context()
+            # ssl exceptions are reported in various form in Python 3
+            # so to be compatible, we report the same kind as under
+            # Python2
+        except excp_lst as e:
+            raise exc.SSLConfigurationError(str(e))
+
+    def set_context(self):
+        """
+        Set up the OpenSSL context.
+        """
+        self.context = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
+        self.context.set_cipher_list(self.CIPHERS)
+
+        if self.ssl_compression is False:
+            self.context.set_options(0x20000)  # SSL_OP_NO_COMPRESSION
+
+        if self.insecure is not True:
+            self.context.set_verify(OpenSSL.SSL.VERIFY_PEER,
+                                    verify_callback(host=self.host))
+        else:
+            self.context.set_verify(OpenSSL.SSL.VERIFY_NONE,
+                                    lambda *args: True)
+
+        if self.cert_file:
+            try:
+                self.context.use_certificate_file(self.cert_file)
+            except Exception as e:
+                msg = 'Unable to load cert from "%s" %s' % (self.cert_file, e)
+                raise exc.SSLConfigurationError(msg)
+            if self.key_file is None:
+                # We support having key and cert in same file
+                try:
+                    self.context.use_privatekey_file(self.cert_file)
+                except Exception as e:
+                    msg = ('No key file specified and unable to load key '
+                           'from "%s" %s' % (self.cert_file, e))
+                    raise exc.SSLConfigurationError(msg)
+
+        if self.key_file:
+            try:
+                self.context.use_privatekey_file(self.key_file)
+            except Exception as e:
+                msg = 'Unable to load key from "%s" %s' % (self.key_file, e)
+                raise exc.SSLConfigurationError(msg)
+
+        if self.cacert:
+            try:
+                self.context.load_verify_locations(to_bytes(self.cacert))
+            except Exception as e:
+                msg = 'Unable to load CA from "%s" %s' % (self.cacert, e)
+                raise exc.SSLConfigurationError(msg)
+        else:
+            self.context.set_default_verify_paths()
+
+    def connect(self):
+        """
+        Connect to an SSL port using the OpenSSL library and apply
+        per-connection parameters.
+        """
+        result = socket.getaddrinfo(self.host, self.port, 0,
+                                    socket.SOCK_STREAM)
+        if result:
+            socket_family = result[0][0]
+            if socket_family == socket.AF_INET6:
+                sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
+            else:
+                sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        else:
+            # If due to some reason the address lookup fails - we still connect
+            # to IPv4 socket. This retains the older behavior.
+            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        if self.timeout is not None:
+            # '0' microseconds
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO,
+                            struct.pack('LL', self.timeout, 0))
+        self.sock = OpenSSLConnectionDelegator(self.context, sock)
+        self.sock.connect((self.host, self.port))