Revision 9279e986

b/lib/rapi/client.py
22 22
"""Ganeti RAPI client."""
23 23

  
24 24
import httplib
25
import httplib2
25
import urllib2
26
import logging
26 27
import simplejson
27 28
import socket
28 29
import urllib
29
from OpenSSL import SSL
30
from OpenSSL import crypto
30
import OpenSSL
31
import distutils.version
31 32

  
32 33

  
34
GANETI_RAPI_PORT = 5080
35

  
33 36
HTTP_DELETE = "DELETE"
34 37
HTTP_GET = "GET"
35 38
HTTP_PUT = "PUT"
36 39
HTTP_POST = "POST"
40
HTTP_OK = 200
41
HTTP_APP_JSON = "application/json"
42

  
37 43
REPLACE_DISK_PRI = "replace_on_primary"
38 44
REPLACE_DISK_SECONDARY = "replace_on_secondary"
39 45
REPLACE_DISK_CHG = "replace_new_secondary"
40 46
REPLACE_DISK_AUTO = "replace_auto"
41 47
VALID_REPLACEMENT_MODES = frozenset([
42
    REPLACE_DISK_PRI, REPLACE_DISK_SECONDARY, REPLACE_DISK_CHG,
43
    REPLACE_DISK_AUTO
44
    ])
48
  REPLACE_DISK_PRI,
49
  REPLACE_DISK_SECONDARY,
50
  REPLACE_DISK_CHG,
51
  REPLACE_DISK_AUTO,
52
  ])
45 53
VALID_NODE_ROLES = frozenset([
46
    "drained", "master", "master-candidate", "offline", "regular"
47
    ])
54
  "drained", "master", "master-candidate", "offline", "regular",
55
  ])
48 56
VALID_STORAGE_TYPES = frozenset(["file", "lvm-pv", "lvm-vg"])
49 57

  
50 58

  
......
90 98
  pass
91 99

  
92 100

  
101
def FormatX509Name(x509_name):
102
  """Formats an X509 name.
103

  
104
  @type x509_name: OpenSSL.crypto.X509Name
105

  
106
  """
107
  try:
108
    # Only supported in pyOpenSSL 0.7 and above
109
    get_components_fn = x509_name.get_components
110
  except AttributeError:
111
    return repr(x509_name)
112
  else:
113
    return "".join("/%s=%s" % (name, value)
114
                   for name, value in get_components_fn())
115

  
116

  
117
class CertAuthorityVerify:
118
  """Certificate verificator for SSL context.
119

  
120
  Configures SSL context to verify server's certificate.
121

  
122
  """
123
  _CAPATH_MINVERSION = "0.9"
124
  _DEFVFYPATHS_MINVERSION = "0.9"
125

  
126
  _PYOPENSSL_VERSION = OpenSSL.__version__
127
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
128

  
129
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
130
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
131

  
132
  def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
133
    """Initializes this class.
134

  
135
    @type cafile: string
136
    @param cafile: In which file we can find the certificates
137
    @type capath: string
138
    @param capath: In which directory we can find the certificates
139
    @type use_default_verify_paths: bool
140
    @param use_default_verify_paths: Whether the platform provided CA
141
                                     certificates are to be used for
142
                                     verification purposes
143

  
144
    """
145
    self._cafile = cafile
146
    self._capath = capath
147
    self._use_default_verify_paths = use_default_verify_paths
148

  
149
    if self._capath is not None and not self._SUPPORT_CAPATH:
150
      raise Error(("PyOpenSSL %s has no support for a CA directory,"
151
                   " version %s or above is required") %
152
                  (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
153

  
154
    if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
155
      raise Error(("PyOpenSSL %s has no support for using default verification"
156
                   " paths, version %s or above is required") %
157
                  (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
158

  
159
  @staticmethod
160
  def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
161
    """Callback for SSL certificate verification.
162

  
163
    @param logger: Logging object
164

  
165
    """
166
    if ok:
167
      log_fn = logger.debug
168
    else:
169
      log_fn = logger.error
170

  
171
    log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
172
           errdepth, FormatX509Name(cert.get_subject()),
173
           FormatX509Name(cert.get_issuer()))
174

  
175
    if not ok:
176
      try:
177
        # Only supported in pyOpenSSL 0.7 and above
178
        # pylint: disable-msg=E1101
179
        fn = OpenSSL.crypto.X509_verify_cert_error_string
180
      except AttributeError:
181
        errmsg = ""
182
      else:
183
        errmsg = ":%s" % fn(errnum)
184

  
185
      logger.error("verify error:num=%s%s", errnum, errmsg)
186

  
187
    return ok
188

  
189
  def __call__(self, ctx, logger):
190
    """Configures an SSL context to verify certificates.
191

  
192
    @type ctx: OpenSSL.SSL.Context
193
    @param ctx: SSL context
194

  
195
    """
196
    if self._use_default_verify_paths:
197
      ctx.set_default_verify_paths()
198

  
199
    if self._cafile or self._capath:
200
      if self._SUPPORT_CAPATH:
201
        ctx.load_verify_locations(self._cafile, self._capath)
202
      else:
203
        ctx.load_verify_locations(self._cafile)
204

  
205
    ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
206
                   lambda conn, cert, errnum, errdepth, ok: \
207
                     self._VerifySslCertCb(logger, conn, cert,
208
                                           errnum, errdepth, ok))
209

  
210

  
211
class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
212
  """HTTPS Connection handler that verifies the SSL certificate.
213

  
214
  """
215
  def __init__(self, *args, **kwargs):
216
    """Initializes this class.
217

  
218
    """
219
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
220
    self._logger = None
221
    self._config_ssl_verification = None
222

  
223
  def Setup(self, logger, config_ssl_verification):
224
    """Sets the SSL verification config function.
225

  
226
    @param logger: Logging object
227
    @type config_ssl_verification: callable
228

  
229
    """
230
    assert self._logger is None
231
    assert self._config_ssl_verification is None
232

  
233
    self._logger = logger
234
    self._config_ssl_verification = config_ssl_verification
235

  
236
  def connect(self):
237
    """Connect to the server specified when the object was created.
238

  
239
    This ensures that SSL certificates are verified.
240

  
241
    """
242
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
243

  
244
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
245
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
246

  
247
    if self._config_ssl_verification:
248
      self._config_ssl_verification(ctx, self._logger)
249

  
250
    ssl = OpenSSL.SSL.Connection(ctx, sock)
251
    ssl.connect((self.host, self.port))
252

  
253
    self.sock = httplib.FakeSocket(sock, ssl)
254

  
255

  
256
class _HTTPSHandler(urllib2.HTTPSHandler):
257
  def __init__(self, logger, config_ssl_verification):
258
    """Initializes this class.
259

  
260
    @param logger: Logging object
261
    @type config_ssl_verification: callable
262
    @param config_ssl_verification: Function to configure SSL context for
263
                                    certificate verification
264

  
265
    """
266
    urllib2.HTTPSHandler.__init__(self)
267
    self._logger = logger
268
    self._config_ssl_verification = config_ssl_verification
269

  
270
  def _CreateHttpsConnection(self, *args, **kwargs):
271
    """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
272

  
273
    This wrapper is necessary provide a compatible API to urllib2.
274

  
275
    """
276
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
277
    conn.Setup(self._logger, self._config_ssl_verification)
278
    return conn
279

  
280
  def https_open(self, req):
281
    """Creates HTTPS connection.
282

  
283
    Called by urllib2.
284

  
285
    """
286
    return self.do_open(self._CreateHttpsConnection, req)
287

  
288

  
289
class _RapiRequest(urllib2.Request):
290
  def __init__(self, method, url, headers, data):
291
    """Initializes this class.
292

  
293
    """
294
    urllib2.Request.__init__(self, url, data=data, headers=headers)
295
    self._method = method
296

  
297
  def get_method(self):
298
    """Returns the HTTP request method.
299

  
300
    """
301
    return self._method
302

  
303

  
93 304
class GanetiRapiClient(object):
94 305
  """Ganeti RAPI client.
95 306

  
96 307
  """
97

  
98 308
  USER_AGENT = "Ganeti RAPI Client"
99 309

  
100
  def __init__(self, master_hostname, port=5080, username=None, password=None,
101
               ssl_cert_file=None):
310
  def __init__(self, host, port=GANETI_RAPI_PORT,
311
               username=None, password=None,
312
               config_ssl_verification=None, ignore_proxy=False,
313
               logger=logging):
102 314
    """Constructor.
103 315

  
104
    @type master_hostname: str
105
    @param master_hostname: the ganeti cluster master to interact with
316
    @type host: string
317
    @param host: the ganeti cluster master to interact with
106 318
    @type port: int
107
    @param port: the port on which the RAPI is running. (default is 5080)
108
    @type username: str
319
    @param port: the port on which the RAPI is running (default is 5080)
320
    @type username: string
109 321
    @param username: the username to connect with
110
    @type password: str
322
    @type password: string
111 323
    @param password: the password to connect with
112
    @type ssl_cert_file: str or None
113
    @param ssl_cert_file: path to the expected SSL certificate. if None, SSL
114
        certificate will not be verified
324
    @type config_ssl_verification: callable
325
    @param config_ssl_verification: Function to configure SSL context for
326
                                    certificate verification
327
    @type ignore_proxy: bool
328
    @param ignore_proxy: Whether to ignore proxy settings
329
    @param logger: Logging object
115 330

  
116 331
    """
117
    self._master_hostname = master_hostname
332
    self._host = host
118 333
    self._port = port
334
    self._logger = logger
119 335

  
120 336
    self._version = None
121
    self._http = httplib2.Http()
122 337

  
123
    # Older versions of httplib2 don't support the connection_type argument
124
    # to request(), so we have to manually specify the connection object in the
125
    # internal dict.
126
    base_url = self._MakeUrl("/", prepend_version=False)
127
    scheme, authority, _, _, _ = httplib2.parse_uri(base_url)
128
    conn_key = "%s:%s" % (scheme, authority)
129
    self._http.connections[conn_key] = \
130
      HTTPSConnectionOpenSSL(master_hostname, port, cert_file=ssl_cert_file)
338
    self._base_url = "https://%s:%s" % (host, port)
131 339

  
132
    self._headers = {
133
        "Accept": "text/plain",
134
        "Content-type": "application/x-www-form-urlencoded",
135
        "User-Agent": self.USER_AGENT}
340
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
341

  
342
    if username is not None:
343
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
344
      pwmgr.add_password(None, self._base_url, username, password)
345
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
346
    elif password:
347
      raise Error("Specified password without username")
348

  
349
    if ignore_proxy:
350
      handlers.append(urllib2.ProxyHandler({}))
351

  
352
    self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142
136 353

  
137
    if username is not None and password is not None:
138
      self._http.add_credentials(username, password)
354
    self._headers = {
355
      "Accept": HTTP_APP_JSON,
356
      "Content-type": HTTP_APP_JSON,
357
      "User-Agent": self.USER_AGENT,
358
      }
139 359

  
140 360
  def _MakeUrl(self, path, query=None, prepend_version=True):
141 361
    """Constructs the URL to pass to the HTTP client.
......
156 376
      path = "/%d%s" % (self.GetVersion(), path)
157 377

  
158 378
    return "https://%(host)s:%(port)d%(path)s?%(query)s" % {
159
        "host": self._master_hostname,
379
        "host": self._host,
160 380
        "port": self._port,
161 381
        "path": path,
162 382
        "query": urllib.urlencode(query or [])}
......
191 411
      content = simplejson.JSONEncoder(sort_keys=True).encode(content)
192 412

  
193 413
    url = self._MakeUrl(path, query, prepend_version)
414

  
415
    req = _RapiRequest(method, url, self._headers, content)
416

  
194 417
    try:
195
      resp_headers, resp_content = self._http.request(url, method,
196
          body=content, headers=self._headers)
197
    except (crypto.Error, SSL.Error):
198
      raise CertificateError("Invalid SSL certificate.")
418
      resp = self._http.open(req)
419
      resp_content = resp.read()
420
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
421
      raise CertificateError("SSL issue: %s" % err)
199 422

  
200 423
    if resp_content:
201 424
      resp_content = simplejson.loads(resp_content)
202 425

  
203 426
    # TODO: Are there other status codes that are valid? (redirect?)
204
    if resp_headers.status != 200:
427
    if resp.code != HTTP_OK:
205 428
      if isinstance(resp_content, dict):
206 429
        msg = ("%s %s: %s" %
207 430
            (resp_content["code"], resp_content["message"],
......
798 1021
      query.append(("dry-run", 1))
799 1022

  
800 1023
    return self._SendRequest(HTTP_DELETE, "/nodes/%s/tags" % node, query)
801

  
802

  
803
class HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
804
  """HTTPS Connection handler that verifies the SSL certificate.
805

  
806
  """
807

  
808
  # pylint: disable-msg=W0142
809
  def __init__(self, *args, **kwargs):
810
    """Constructor.
811

  
812
    """
813
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
814

  
815
    self._ssl_cert = None
816
    if self.cert_file:
817
      f = open(self.cert_file, "r")
818
      self._ssl_cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
819
      f.close()
820

  
821
  # pylint: disable-msg=W0613
822
  def _VerifySSLCertCallback(self, conn, cert, errnum, errdepth, ok):
823
    """Verifies the SSL certificate provided by the peer.
824

  
825
    """
826
    return (self._ssl_cert.digest("sha1") == cert.digest("sha1") and
827
            self._ssl_cert.digest("md5") == cert.digest("md5"))
828

  
829
  def connect(self):
830
    """Connect to the server specified when the object was created.
831

  
832
    This ensures that SSL certificates are verified.
833

  
834
    """
835
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
836
    ctx = SSL.Context(SSL.SSLv23_METHOD)
837
    ctx.set_options(SSL.OP_NO_SSLv2)
838
    ctx.use_certificate(self._ssl_cert)
839
    ctx.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT,
840
                   self._VerifySSLCertCallback)
841

  
842
    ssl = SSL.Connection(ctx, sock)
843
    ssl.connect((self.host, self.port))
844
    self.sock = httplib.FakeSocket(sock, ssl)
b/test/ganeti.rapi.client_unittest.py
22 22
"""Script for unittesting the RAPI client module"""
23 23

  
24 24

  
25
try:
26
  import httplib2
27
  BaseHttp = httplib2.Http
28
  from ganeti.rapi import client
29
except ImportError:
30
  httplib2 = None
31
  BaseHttp = object
32

  
33 25
import re
34 26
import unittest
35 27
import warnings
......
38 30

  
39 31
from ganeti.rapi import connector
40 32
from ganeti.rapi import rlib2
33
from ganeti.rapi import client
41 34

  
42 35
import testutils
43 36

  
......
56 49
    return None
57 50

  
58 51

  
59
class HttpResponseMock(dict):
60
  """Dumb mock of httplib2.Response.
52
class HttpResponseMock:
53
  """Dumb mock of httplib.HTTPResponse.
61 54

  
62 55
  """
63 56

  
64
  def __init__(self, status):
65
    self.status = status
66
    self['status'] = status
57
  def __init__(self, code, data):
58
    self.code = code
59
    self._data = data
60

  
61
  def read(self):
62
    return self._data
67 63

  
68 64

  
69
class HttpMock(BaseHttp):
70
  """Mock for httplib.Http.
65
class OpenerDirectorMock:
66
  """Mock for urllib.OpenerDirector.
71 67

  
72 68
  """
73 69

  
74 70
  def __init__(self, rapi):
75 71
    self._rapi = rapi
76
    self._last_request = None
72
    self.last_request = None
77 73

  
78
  last_request_url = property(lambda self: self._last_request[0])
79
  last_request_method = property(lambda self: self._last_request[1])
80
  last_request_body = property(lambda self: self._last_request[2])
74
  def open(self, req):
75
    self.last_request = req
81 76

  
82
  def request(self, url, method, body, headers):
83
    self._last_request = (url, method, body)
84
    code, resp_body = self._rapi.FetchResponse(_GetPathFromUri(url), method)
85
    return HttpResponseMock(code), resp_body
77
    path = _GetPathFromUri(req.get_full_url())
78
    code, resp_body = self._rapi.FetchResponse(path, req.get_method())
79
    return HttpResponseMock(code, resp_body)
86 80

  
87 81

  
88 82
class RapiMock(object):
......
146 140

  
147 141
  def setUp(self):
148 142
    self.rapi = RapiMock()
149
    self.http = HttpMock(self.rapi)
143
    self.http = OpenerDirectorMock(self.rapi)
150 144
    self.client = client.GanetiRapiClient('master.foo.com')
151 145
    self.client._http = self.http
152 146
    # Hard-code the version for easier testing.
......
384 378
    self.assertHandler(rlib2.R_2_nodes_name_role)
385 379
    self.assertItems(["node-foo"])
386 380
    self.assertQuery("force", ["True"])
387
    self.assertEqual("\"master-candidate\"", self.http.last_request_body)
381
    self.assertEqual("\"master-candidate\"", self.http.last_request.data)
388 382

  
389 383
    self.assertRaises(client.InvalidNodeRole,
390 384
                      self.client.SetNodeRole, "node-bar", "fake-role")
......
439 433

  
440 434

  
441 435
if __name__ == '__main__':
442
  if httplib2 is None:
443
    warnings.warn("These tests require the httplib2 library")
444
  else:
445
    testutils.GanetiTestProgram()
436
  testutils.GanetiTestProgram()

Also available in: Unified diff