From 9279e986ea8d24d570c1d0cbca54590430ad538a Mon Sep 17 00:00:00 2001 From: Michael Hanselmann <hansmi@google.com> Date: Fri, 7 May 2010 19:05:38 +0200 Subject: [PATCH] Remove httplib2 dependency from ganeti.rapi.client - It's possible to implement all functionality in ganeti.rapi.client using Python's standard modules httplib and urllib2 - By doing so, proper SSL certificate verification is implemented - Adjust some of the code to Ganeti's code style (this is not yet finished) Signed-off-by: Michael Hanselmann <hansmi@google.com> Reviewed-by: Iustin Pop <iustin@google.com> --- lib/rapi/client.py | 349 +++++++++++++++++++++------- test/ganeti.rapi.client_unittest.py | 49 ++-- 2 files changed, 284 insertions(+), 114 deletions(-) diff --git a/lib/rapi/client.py b/lib/rapi/client.py index bacd64a70..70678f46f 100644 --- a/lib/rapi/client.py +++ b/lib/rapi/client.py @@ -22,29 +22,37 @@ """Ganeti RAPI client.""" import httplib -import httplib2 +import urllib2 +import logging import simplejson import socket import urllib -from OpenSSL import SSL -from OpenSSL import crypto +import OpenSSL +import distutils.version +GANETI_RAPI_PORT = 5080 + HTTP_DELETE = "DELETE" HTTP_GET = "GET" HTTP_PUT = "PUT" HTTP_POST = "POST" +HTTP_OK = 200 +HTTP_APP_JSON = "application/json" + REPLACE_DISK_PRI = "replace_on_primary" REPLACE_DISK_SECONDARY = "replace_on_secondary" REPLACE_DISK_CHG = "replace_new_secondary" REPLACE_DISK_AUTO = "replace_auto" VALID_REPLACEMENT_MODES = frozenset([ - REPLACE_DISK_PRI, REPLACE_DISK_SECONDARY, REPLACE_DISK_CHG, - REPLACE_DISK_AUTO - ]) + REPLACE_DISK_PRI, + REPLACE_DISK_SECONDARY, + REPLACE_DISK_CHG, + REPLACE_DISK_AUTO, + ]) VALID_NODE_ROLES = frozenset([ - "drained", "master", "master-candidate", "offline", "regular" - ]) + "drained", "master", "master-candidate", "offline", "regular", + ]) VALID_STORAGE_TYPES = frozenset(["file", "lvm-pv", "lvm-vg"]) @@ -90,52 +98,264 @@ class InvalidNodeRole(Error): pass +def FormatX509Name(x509_name): + """Formats an X509 name. + + @type x509_name: OpenSSL.crypto.X509Name + + """ + try: + # Only supported in pyOpenSSL 0.7 and above + get_components_fn = x509_name.get_components + except AttributeError: + return repr(x509_name) + else: + return "".join("/%s=%s" % (name, value) + for name, value in get_components_fn()) + + +class CertAuthorityVerify: + """Certificate verificator for SSL context. + + Configures SSL context to verify server's certificate. + + """ + _CAPATH_MINVERSION = "0.9" + _DEFVFYPATHS_MINVERSION = "0.9" + + _PYOPENSSL_VERSION = OpenSSL.__version__ + _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION) + + _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION) + _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION) + + def __init__(self, cafile=None, capath=None, use_default_verify_paths=False): + """Initializes this class. + + @type cafile: string + @param cafile: In which file we can find the certificates + @type capath: string + @param capath: In which directory we can find the certificates + @type use_default_verify_paths: bool + @param use_default_verify_paths: Whether the platform provided CA + certificates are to be used for + verification purposes + + """ + self._cafile = cafile + self._capath = capath + self._use_default_verify_paths = use_default_verify_paths + + if self._capath is not None and not self._SUPPORT_CAPATH: + raise Error(("PyOpenSSL %s has no support for a CA directory," + " version %s or above is required") % + (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION)) + + if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS: + raise Error(("PyOpenSSL %s has no support for using default verification" + " paths, version %s or above is required") % + (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION)) + + @staticmethod + def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok): + """Callback for SSL certificate verification. + + @param logger: Logging object + + """ + if ok: + log_fn = logger.debug + else: + log_fn = logger.error + + log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'", + errdepth, FormatX509Name(cert.get_subject()), + FormatX509Name(cert.get_issuer())) + + if not ok: + try: + # Only supported in pyOpenSSL 0.7 and above + # pylint: disable-msg=E1101 + fn = OpenSSL.crypto.X509_verify_cert_error_string + except AttributeError: + errmsg = "" + else: + errmsg = ":%s" % fn(errnum) + + logger.error("verify error:num=%s%s", errnum, errmsg) + + return ok + + def __call__(self, ctx, logger): + """Configures an SSL context to verify certificates. + + @type ctx: OpenSSL.SSL.Context + @param ctx: SSL context + + """ + if self._use_default_verify_paths: + ctx.set_default_verify_paths() + + if self._cafile or self._capath: + if self._SUPPORT_CAPATH: + ctx.load_verify_locations(self._cafile, self._capath) + else: + ctx.load_verify_locations(self._cafile) + + ctx.set_verify(OpenSSL.SSL.VERIFY_PEER, + lambda conn, cert, errnum, errdepth, ok: \ + self._VerifySslCertCb(logger, conn, cert, + errnum, errdepth, ok)) + + +class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection): + """HTTPS Connection handler that verifies the SSL certificate. + + """ + def __init__(self, *args, **kwargs): + """Initializes this class. + + """ + httplib.HTTPSConnection.__init__(self, *args, **kwargs) + self._logger = None + self._config_ssl_verification = None + + def Setup(self, logger, config_ssl_verification): + """Sets the SSL verification config function. + + @param logger: Logging object + @type config_ssl_verification: callable + + """ + assert self._logger is None + assert self._config_ssl_verification is None + + self._logger = logger + self._config_ssl_verification = config_ssl_verification + + def connect(self): + """Connect to the server specified when the object was created. + + This ensures that SSL certificates are verified. + + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2) + + if self._config_ssl_verification: + self._config_ssl_verification(ctx, self._logger) + + ssl = OpenSSL.SSL.Connection(ctx, sock) + ssl.connect((self.host, self.port)) + + self.sock = httplib.FakeSocket(sock, ssl) + + +class _HTTPSHandler(urllib2.HTTPSHandler): + def __init__(self, logger, config_ssl_verification): + """Initializes this class. + + @param logger: Logging object + @type config_ssl_verification: callable + @param config_ssl_verification: Function to configure SSL context for + certificate verification + + """ + urllib2.HTTPSHandler.__init__(self) + self._logger = logger + self._config_ssl_verification = config_ssl_verification + + def _CreateHttpsConnection(self, *args, **kwargs): + """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification. + + This wrapper is necessary provide a compatible API to urllib2. + + """ + conn = _HTTPSConnectionOpenSSL(*args, **kwargs) + conn.Setup(self._logger, self._config_ssl_verification) + return conn + + def https_open(self, req): + """Creates HTTPS connection. + + Called by urllib2. + + """ + return self.do_open(self._CreateHttpsConnection, req) + + +class _RapiRequest(urllib2.Request): + def __init__(self, method, url, headers, data): + """Initializes this class. + + """ + urllib2.Request.__init__(self, url, data=data, headers=headers) + self._method = method + + def get_method(self): + """Returns the HTTP request method. + + """ + return self._method + + class GanetiRapiClient(object): """Ganeti RAPI client. """ - USER_AGENT = "Ganeti RAPI Client" - def __init__(self, master_hostname, port=5080, username=None, password=None, - ssl_cert_file=None): + def __init__(self, host, port=GANETI_RAPI_PORT, + username=None, password=None, + config_ssl_verification=None, ignore_proxy=False, + logger=logging): """Constructor. - @type master_hostname: str - @param master_hostname: the ganeti cluster master to interact with + @type host: string + @param host: the ganeti cluster master to interact with @type port: int - @param port: the port on which the RAPI is running. (default is 5080) - @type username: str + @param port: the port on which the RAPI is running (default is 5080) + @type username: string @param username: the username to connect with - @type password: str + @type password: string @param password: the password to connect with - @type ssl_cert_file: str or None - @param ssl_cert_file: path to the expected SSL certificate. if None, SSL - certificate will not be verified + @type config_ssl_verification: callable + @param config_ssl_verification: Function to configure SSL context for + certificate verification + @type ignore_proxy: bool + @param ignore_proxy: Whether to ignore proxy settings + @param logger: Logging object """ - self._master_hostname = master_hostname + self._host = host self._port = port + self._logger = logger self._version = None - self._http = httplib2.Http() - # Older versions of httplib2 don't support the connection_type argument - # to request(), so we have to manually specify the connection object in the - # internal dict. - base_url = self._MakeUrl("/", prepend_version=False) - scheme, authority, _, _, _ = httplib2.parse_uri(base_url) - conn_key = "%s:%s" % (scheme, authority) - self._http.connections[conn_key] = \ - HTTPSConnectionOpenSSL(master_hostname, port, cert_file=ssl_cert_file) + self._base_url = "https://%s:%s" % (host, port) - self._headers = { - "Accept": "text/plain", - "Content-type": "application/x-www-form-urlencoded", - "User-Agent": self.USER_AGENT} + handlers = [_HTTPSHandler(self._logger, config_ssl_verification)] + + if username is not None: + pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm() + pwmgr.add_password(None, self._base_url, username, password) + handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr)) + elif password: + raise Error("Specified password without username") + + if ignore_proxy: + handlers.append(urllib2.ProxyHandler({})) + + self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142 - if username is not None and password is not None: - self._http.add_credentials(username, password) + self._headers = { + "Accept": HTTP_APP_JSON, + "Content-type": HTTP_APP_JSON, + "User-Agent": self.USER_AGENT, + } def _MakeUrl(self, path, query=None, prepend_version=True): """Constructs the URL to pass to the HTTP client. @@ -156,7 +376,7 @@ class GanetiRapiClient(object): path = "/%d%s" % (self.GetVersion(), path) return "https://%(host)s:%(port)d%(path)s?%(query)s" % { - "host": self._master_hostname, + "host": self._host, "port": self._port, "path": path, "query": urllib.urlencode(query or [])} @@ -191,17 +411,20 @@ class GanetiRapiClient(object): content = simplejson.JSONEncoder(sort_keys=True).encode(content) url = self._MakeUrl(path, query, prepend_version) + + req = _RapiRequest(method, url, self._headers, content) + try: - resp_headers, resp_content = self._http.request(url, method, - body=content, headers=self._headers) - except (crypto.Error, SSL.Error): - raise CertificateError("Invalid SSL certificate.") + resp = self._http.open(req) + resp_content = resp.read() + except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err: + raise CertificateError("SSL issue: %s" % err) if resp_content: resp_content = simplejson.loads(resp_content) # TODO: Are there other status codes that are valid? (redirect?) - if resp_headers.status != 200: + if resp.code != HTTP_OK: if isinstance(resp_content, dict): msg = ("%s %s: %s" % (resp_content["code"], resp_content["message"], @@ -798,47 +1021,3 @@ class GanetiRapiClient(object): query.append(("dry-run", 1)) return self._SendRequest(HTTP_DELETE, "/nodes/%s/tags" % node, query) - - -class HTTPSConnectionOpenSSL(httplib.HTTPSConnection): - """HTTPS Connection handler that verifies the SSL certificate. - - """ - - # pylint: disable-msg=W0142 - def __init__(self, *args, **kwargs): - """Constructor. - - """ - httplib.HTTPSConnection.__init__(self, *args, **kwargs) - - self._ssl_cert = None - if self.cert_file: - f = open(self.cert_file, "r") - self._ssl_cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) - f.close() - - # pylint: disable-msg=W0613 - def _VerifySSLCertCallback(self, conn, cert, errnum, errdepth, ok): - """Verifies the SSL certificate provided by the peer. - - """ - return (self._ssl_cert.digest("sha1") == cert.digest("sha1") and - self._ssl_cert.digest("md5") == cert.digest("md5")) - - def connect(self): - """Connect to the server specified when the object was created. - - This ensures that SSL certificates are verified. - - """ - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - ctx = SSL.Context(SSL.SSLv23_METHOD) - ctx.set_options(SSL.OP_NO_SSLv2) - ctx.use_certificate(self._ssl_cert) - ctx.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, - self._VerifySSLCertCallback) - - ssl = SSL.Connection(ctx, sock) - ssl.connect((self.host, self.port)) - self.sock = httplib.FakeSocket(sock, ssl) diff --git a/test/ganeti.rapi.client_unittest.py b/test/ganeti.rapi.client_unittest.py index 2f4830ebe..546d1c453 100755 --- a/test/ganeti.rapi.client_unittest.py +++ b/test/ganeti.rapi.client_unittest.py @@ -22,14 +22,6 @@ """Script for unittesting the RAPI client module""" -try: - import httplib2 - BaseHttp = httplib2.Http - from ganeti.rapi import client -except ImportError: - httplib2 = None - BaseHttp = object - import re import unittest import warnings @@ -38,6 +30,7 @@ from ganeti import http from ganeti.rapi import connector from ganeti.rapi import rlib2 +from ganeti.rapi import client import testutils @@ -56,33 +49,34 @@ def _GetPathFromUri(uri): return None -class HttpResponseMock(dict): - """Dumb mock of httplib2.Response. +class HttpResponseMock: + """Dumb mock of httplib.HTTPResponse. """ - def __init__(self, status): - self.status = status - self['status'] = status + def __init__(self, code, data): + self.code = code + self._data = data + + def read(self): + return self._data -class HttpMock(BaseHttp): - """Mock for httplib.Http. +class OpenerDirectorMock: + """Mock for urllib.OpenerDirector. """ def __init__(self, rapi): self._rapi = rapi - self._last_request = None + self.last_request = None - last_request_url = property(lambda self: self._last_request[0]) - last_request_method = property(lambda self: self._last_request[1]) - last_request_body = property(lambda self: self._last_request[2]) + def open(self, req): + self.last_request = req - def request(self, url, method, body, headers): - self._last_request = (url, method, body) - code, resp_body = self._rapi.FetchResponse(_GetPathFromUri(url), method) - return HttpResponseMock(code), resp_body + path = _GetPathFromUri(req.get_full_url()) + code, resp_body = self._rapi.FetchResponse(path, req.get_method()) + return HttpResponseMock(code, resp_body) class RapiMock(object): @@ -146,7 +140,7 @@ class GanetiRapiClientTests(unittest.TestCase): def setUp(self): self.rapi = RapiMock() - self.http = HttpMock(self.rapi) + self.http = OpenerDirectorMock(self.rapi) self.client = client.GanetiRapiClient('master.foo.com') self.client._http = self.http # Hard-code the version for easier testing. @@ -384,7 +378,7 @@ class GanetiRapiClientTests(unittest.TestCase): self.assertHandler(rlib2.R_2_nodes_name_role) self.assertItems(["node-foo"]) self.assertQuery("force", ["True"]) - self.assertEqual("\"master-candidate\"", self.http.last_request_body) + self.assertEqual("\"master-candidate\"", self.http.last_request.data) self.assertRaises(client.InvalidNodeRole, self.client.SetNodeRole, "node-bar", "fake-role") @@ -439,7 +433,4 @@ class GanetiRapiClientTests(unittest.TestCase): if __name__ == '__main__': - if httplib2 is None: - warnings.warn("These tests require the httplib2 library") - else: - testutils.GanetiTestProgram() + testutils.GanetiTestProgram() -- GitLab