Revision 2a7c3583 lib/rapi/client.py

b/lib/rapi/client.py
19 19
# 02110-1301, USA.
20 20

  
21 21

  
22
"""Ganeti RAPI client."""
22
"""Ganeti RAPI client.
23

  
24
@attention: To use the RAPI client, the application B{must} call
25
            C{pycurl.global_init} during initialization and
26
            C{pycurl.global_cleanup} before exiting the process. This is very
27
            important in multi-threaded programs. See curl_global_init(3) and
28
            curl_global_cleanup(3) for details. The decorator L{UsesRapiClient}
29
            can be used.
30

  
31
"""
23 32

  
24 33
# No Ganeti-specific modules should be imported. The RAPI client is supposed to
25 34
# be standalone.
26 35

  
27
import sys
28
import httplib
29
import urllib2
30 36
import logging
31 37
import simplejson
32
import socket
33 38
import urllib
34
import OpenSSL
35
import distutils.version
39
import threading
40
import pycurl
41

  
42
try:
43
  from cStringIO import StringIO
44
except ImportError:
45
  from StringIO import StringIO
36 46

  
37 47

  
38 48
GANETI_RAPI_PORT = 5080
......
61 71
_REQ_DATA_VERSION_FIELD = "__version__"
62 72
_INST_CREATE_REQV1 = "instance-create-reqv1"
63 73

  
74
# Older pycURL versions don't have all error constants
75
try:
76
  _CURLE_SSL_CACERT = pycurl.E_SSL_CACERT
77
  _CURLE_SSL_CACERT_BADFILE = pycurl.E_SSL_CACERT_BADFILE
78
except AttributeError:
79
  _CURLE_SSL_CACERT = 60
80
  _CURLE_SSL_CACERT_BADFILE = 77
81

  
82
_CURL_SSL_CERT_ERRORS = frozenset([
83
  _CURLE_SSL_CACERT,
84
  _CURLE_SSL_CACERT_BADFILE,
85
  ])
86

  
64 87

  
65 88
class Error(Exception):
66 89
  """Base error class for this module.
......
85 108
    self.code = code
86 109

  
87 110

  
88
def FormatX509Name(x509_name):
89
  """Formats an X509 name.
90

  
91
  @type x509_name: OpenSSL.crypto.X509Name
111
def UsesRapiClient(fn):
112
  """Decorator for code using RAPI client to initialize pycURL.
92 113

  
93 114
  """
94
  try:
95
    # Only supported in pyOpenSSL 0.7 and above
96
    get_components_fn = x509_name.get_components
97
  except AttributeError:
98
    return repr(x509_name)
99
  else:
100
    return "".join("/%s=%s" % (name, value)
101
                   for name, value in get_components_fn())
102

  
103

  
104
class CertAuthorityVerify:
105
  """Certificate verificator for SSL context.
106

  
107
  Configures SSL context to verify server's certificate.
115
  def wrapper(*args, **kwargs):
116
    # curl_global_init(3) and curl_global_cleanup(3) must be called with only
117
    # one thread running. This check is just a safety measure -- it doesn't
118
    # cover all cases.
119
    assert threading.activeCount() == 1, \
120
           "Found active threads when initializing pycURL"
121

  
122
    pycurl.global_init(pycurl.GLOBAL_ALL)
123
    try:
124
      return fn(*args, **kwargs)
125
    finally:
126
      pycurl.global_cleanup()
127

  
128
  return wrapper
129

  
130

  
131
def GenericCurlConfig(verbose=False, use_signal=False,
132
                      use_curl_cabundle=False, cafile=None, capath=None,
133
                      proxy=None, verify_hostname=False,
134
                      connect_timeout=None, timeout=None,
135
                      _pycurl_version_fn=pycurl.version_info):
136
  """Curl configuration function generator.
137

  
138
  @type verbose: bool
139
  @param verbose: Whether to set cURL to verbose mode
140
  @type use_signal: bool
141
  @param use_signal: Whether to allow cURL to use signals
142
  @type use_curl_cabundle: bool
143
  @param use_curl_cabundle: Whether to use cURL's default CA bundle
144
  @type cafile: string
145
  @param cafile: In which file we can find the certificates
146
  @type capath: string
147
  @param capath: In which directory we can find the certificates
148
  @type proxy: string
149
  @param proxy: Proxy to use, None for default behaviour and empty string for
150
                disabling proxies (see curl_easy_setopt(3))
151
  @type verify_hostname: bool
152
  @param verify_hostname: Whether to verify the remote peer certificate's
153
                          commonName
154
  @type connect_timeout: number
155
  @param connect_timeout: Timeout for establishing connection in seconds
156
  @type timeout: number
157
  @param timeout: Timeout for complete transfer in seconds (see
158
                  curl_easy_setopt(3)).
108 159

  
109 160
  """
110
  _CAPATH_MINVERSION = "0.9"
111
  _DEFVFYPATHS_MINVERSION = "0.9"
161
  if use_curl_cabundle and (cafile or capath):
162
    raise Error("Can not use default CA bundle when CA file or path is set")
112 163

  
113
  _PYOPENSSL_VERSION = OpenSSL.__version__
114
  _PARSED_PYOPENSSL_VERSION = distutils.version.LooseVersion(_PYOPENSSL_VERSION)
115

  
116
  _SUPPORT_CAPATH = (_PARSED_PYOPENSSL_VERSION >= _CAPATH_MINVERSION)
117
  _SUPPORT_DEFVFYPATHS = (_PARSED_PYOPENSSL_VERSION >= _DEFVFYPATHS_MINVERSION)
118

  
119
  def __init__(self, cafile=None, capath=None, use_default_verify_paths=False):
120
    """Initializes this class.
164
  def _ConfigCurl(curl, logger):
165
    """Configures a cURL object
121 166

  
122
    @type cafile: string
123
    @param cafile: In which file we can find the certificates
124
    @type capath: string
125
    @param capath: In which directory we can find the certificates
126
    @type use_default_verify_paths: bool
127
    @param use_default_verify_paths: Whether the platform provided CA
128
                                     certificates are to be used for
129
                                     verification purposes
167
    @type curl: pycurl.Curl
168
    @param curl: cURL object
130 169

  
131 170
    """
132
    self._cafile = cafile
133
    self._capath = capath
134
    self._use_default_verify_paths = use_default_verify_paths
135

  
136
    if self._capath is not None and not self._SUPPORT_CAPATH:
137
      raise Error(("PyOpenSSL %s has no support for a CA directory,"
138
                   " version %s or above is required") %
139
                  (self._PYOPENSSL_VERSION, self._CAPATH_MINVERSION))
140

  
141
    if self._use_default_verify_paths and not self._SUPPORT_DEFVFYPATHS:
142
      raise Error(("PyOpenSSL %s has no support for using default verification"
143
                   " paths, version %s or above is required") %
144
                  (self._PYOPENSSL_VERSION, self._DEFVFYPATHS_MINVERSION))
145

  
146
  @staticmethod
147
  def _VerifySslCertCb(logger, _, cert, errnum, errdepth, ok):
148
    """Callback for SSL certificate verification.
149

  
150
    @param logger: Logging object
151

  
152
    """
153
    if ok:
154
      log_fn = logger.debug
171
    logger.debug("Using cURL version %s", pycurl.version)
172

  
173
    # pycurl.version_info returns a tuple with information about the used
174
    # version of libcurl. Item 5 is the SSL library linked to it.
175
    # e.g.: (3, '7.18.0', 463360, 'x86_64-pc-linux-gnu', 1581, 'GnuTLS/2.0.4',
176
    # 0, '1.2.3.3', ...)
177
    sslver = _pycurl_version_fn()[5]
178
    if not sslver:
179
      raise Error("No SSL support in cURL")
180

  
181
    lcsslver = sslver.lower()
182
    if lcsslver.startswith("openssl/"):
183
      pass
184
    elif lcsslver.startswith("gnutls/"):
185
      if capath:
186
        raise Error("cURL linked against GnuTLS has no support for a"
187
                    " CA path (%s)" % (pycurl.version, ))
155 188
    else:
156
      log_fn = logger.error
157

  
158
    log_fn("Verifying SSL certificate at depth %s, subject '%s', issuer '%s'",
159
           errdepth, FormatX509Name(cert.get_subject()),
160
           FormatX509Name(cert.get_issuer()))
161

  
162
    if not ok:
163
      try:
164
        # Only supported in pyOpenSSL 0.7 and above
165
        # pylint: disable-msg=E1101
166
        fn = OpenSSL.crypto.X509_verify_cert_error_string
167
      except AttributeError:
168
        errmsg = ""
169
      else:
170
        errmsg = ":%s" % fn(errnum)
171

  
172
      logger.error("verify error:num=%s%s", errnum, errmsg)
173

  
174
    return ok
175

  
176
  def __call__(self, ctx, logger):
177
    """Configures an SSL context to verify certificates.
178

  
179
    @type ctx: OpenSSL.SSL.Context
180
    @param ctx: SSL context
181

  
182
    """
183
    if self._use_default_verify_paths:
184
      ctx.set_default_verify_paths()
185

  
186
    if self._cafile or self._capath:
187
      if self._SUPPORT_CAPATH:
188
        ctx.load_verify_locations(self._cafile, self._capath)
189
      else:
190
        ctx.load_verify_locations(self._cafile)
191

  
192
    ctx.set_verify(OpenSSL.SSL.VERIFY_PEER,
193
                   lambda conn, cert, errnum, errdepth, ok: \
194
                     self._VerifySslCertCb(logger, conn, cert,
195
                                           errnum, errdepth, ok))
196

  
197

  
198
class _HTTPSConnectionOpenSSL(httplib.HTTPSConnection):
199
  """HTTPS Connection handler that verifies the SSL certificate.
200

  
201
  """
202
  # Python before version 2.6 had its own httplib.FakeSocket wrapper for
203
  # sockets
204
  _SUPPORT_FAKESOCKET = (sys.hexversion < 0x2060000)
205

  
206
  def __init__(self, *args, **kwargs):
207
    """Initializes this class.
208

  
209
    """
210
    httplib.HTTPSConnection.__init__(self, *args, **kwargs)
211
    self._logger = None
212
    self._config_ssl_verification = None
213

  
214
  def Setup(self, logger, config_ssl_verification):
215
    """Sets the SSL verification config function.
216

  
217
    @param logger: Logging object
218
    @type config_ssl_verification: callable
219

  
220
    """
221
    assert self._logger is None
222
    assert self._config_ssl_verification is None
223

  
224
    self._logger = logger
225
    self._config_ssl_verification = config_ssl_verification
226

  
227
  def connect(self):
228
    """Connect to the server specified when the object was created.
229

  
230
    This ensures that SSL certificates are verified.
231

  
232
    """
233
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
234

  
235
    ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
236
    ctx.set_options(OpenSSL.SSL.OP_NO_SSLv2)
237

  
238
    if self._config_ssl_verification:
239
      self._config_ssl_verification(ctx, self._logger)
240

  
241
    ssl = OpenSSL.SSL.Connection(ctx, sock)
242
    ssl.connect((self.host, self.port))
243

  
244
    if self._SUPPORT_FAKESOCKET:
245
      self.sock = httplib.FakeSocket(sock, ssl)
189
      raise NotImplementedError("cURL uses unsupported SSL version '%s'" %
190
                                sslver)
191

  
192
    curl.setopt(pycurl.VERBOSE, verbose)
193
    curl.setopt(pycurl.NOSIGNAL, not use_signal)
194

  
195
    # Whether to verify remote peer's CN
196
    if verify_hostname:
197
      # curl_easy_setopt(3): "When CURLOPT_SSL_VERIFYHOST is 2, that
198
      # certificate must indicate that the server is the server to which you
199
      # meant to connect, or the connection fails. [...] When the value is 1,
200
      # the certificate must contain a Common Name field, but it doesn't matter
201
      # what name it says. [...]"
202
      curl.setopt(pycurl.SSL_VERIFYHOST, 2)
246 203
    else:
247
      self.sock = _SslSocketWrapper(ssl)
248

  
249

  
250
class _SslSocketWrapper(object):
251
  def __init__(self, sock):
252
    """Initializes this class.
253

  
254
    """
255
    self._sock = sock
256

  
257
  def __getattr__(self, name):
258
    """Forward everything to underlying socket.
259

  
260
    """
261
    return getattr(self._sock, name)
262

  
263
  def makefile(self, mode, bufsize):
264
    """Fake makefile method.
265

  
266
    makefile() on normal file descriptors uses dup2(2), which doesn't work with
267
    SSL sockets and therefore is not implemented by pyOpenSSL. This fake method
268
    works with the httplib module, but might not work for other modules.
269

  
270
    """
271
    # pylint: disable-msg=W0212
272
    return socket._fileobject(self._sock, mode, bufsize)
273

  
274

  
275
class _HTTPSHandler(urllib2.HTTPSHandler):
276
  def __init__(self, logger, config_ssl_verification):
277
    """Initializes this class.
278

  
279
    @param logger: Logging object
280
    @type config_ssl_verification: callable
281
    @param config_ssl_verification: Function to configure SSL context for
282
                                    certificate verification
283

  
284
    """
285
    urllib2.HTTPSHandler.__init__(self)
286
    self._logger = logger
287
    self._config_ssl_verification = config_ssl_verification
288

  
289
  def _CreateHttpsConnection(self, *args, **kwargs):
290
    """Wrapper around L{_HTTPSConnectionOpenSSL} to add SSL verification.
291

  
292
    This wrapper is necessary provide a compatible API to urllib2.
293

  
294
    """
295
    conn = _HTTPSConnectionOpenSSL(*args, **kwargs)
296
    conn.Setup(self._logger, self._config_ssl_verification)
297
    return conn
298

  
299
  def https_open(self, req):
300
    """Creates HTTPS connection.
301

  
302
    Called by urllib2.
303

  
304
    """
305
    return self.do_open(self._CreateHttpsConnection, req)
306

  
307

  
308
class _RapiRequest(urllib2.Request):
309
  def __init__(self, method, url, headers, data):
310
    """Initializes this class.
204
      curl.setopt(pycurl.SSL_VERIFYHOST, 0)
205

  
206
    if cafile or capath or use_curl_cabundle:
207
      # Require certificates to be checked
208
      curl.setopt(pycurl.SSL_VERIFYPEER, True)
209
      if cafile:
210
        curl.setopt(pycurl.CAINFO, str(cafile))
211
      if capath:
212
        curl.setopt(pycurl.CAPATH, str(capath))
213
      # Not changing anything for using default CA bundle
214
    else:
215
      # Disable SSL certificate verification
216
      curl.setopt(pycurl.SSL_VERIFYPEER, False)
311 217

  
312
    """
313
    urllib2.Request.__init__(self, url, data=data, headers=headers)
314
    self._method = method
218
    if proxy is not None:
219
      curl.setopt(pycurl.PROXY, str(proxy))
315 220

  
316
  def get_method(self):
317
    """Returns the HTTP request method.
221
    # Timeouts
222
    if connect_timeout is not None:
223
      curl.setopt(pycurl.CONNECTTIMEOUT, connect_timeout)
224
    if timeout is not None:
225
      curl.setopt(pycurl.TIMEOUT, timeout)
318 226

  
319
    """
320
    return self._method
227
  return _ConfigCurl
321 228

  
322 229

  
323 230
class GanetiRapiClient(object):
......
328 235
  _json_encoder = simplejson.JSONEncoder(sort_keys=True)
329 236

  
330 237
  def __init__(self, host, port=GANETI_RAPI_PORT,
331
               username=None, password=None,
332
               config_ssl_verification=None, ignore_proxy=False,
333
               logger=logging):
334
    """Constructor.
238
               username=None, password=None, logger=logging,
239
               curl_config_fn=None, curl=None):
240
    """Initializes this class.
335 241

  
336 242
    @type host: string
337 243
    @param host: the ganeti cluster master to interact with
......
341 247
    @param username: the username to connect with
342 248
    @type password: string
343 249
    @param password: the password to connect with
344
    @type config_ssl_verification: callable
345
    @param config_ssl_verification: Function to configure SSL context for
346
                                    certificate verification
347
    @type ignore_proxy: bool
348
    @param ignore_proxy: Whether to ignore proxy settings
250
    @type curl_config_fn: callable
251
    @param curl_config_fn: Function to configure C{pycurl.Curl} object
349 252
    @param logger: Logging object
350 253

  
351 254
    """
......
355 258

  
356 259
    self._base_url = "https://%s:%s" % (host, port)
357 260

  
358
    handlers = [_HTTPSHandler(self._logger, config_ssl_verification)]
359

  
261
    # Create pycURL object if not supplied
262
    if not curl:
263
      curl = pycurl.Curl()
264

  
265
    # Default cURL settings
266
    curl.setopt(pycurl.VERBOSE, False)
267
    curl.setopt(pycurl.FOLLOWLOCATION, False)
268
    curl.setopt(pycurl.MAXREDIRS, 5)
269
    curl.setopt(pycurl.NOSIGNAL, True)
270
    curl.setopt(pycurl.USERAGENT, self.USER_AGENT)
271
    curl.setopt(pycurl.SSL_VERIFYHOST, 0)
272
    curl.setopt(pycurl.SSL_VERIFYPEER, False)
273
    curl.setopt(pycurl.HTTPHEADER, [
274
      "Accept: %s" % HTTP_APP_JSON,
275
      "Content-type: %s" % HTTP_APP_JSON,
276
      ])
277

  
278
    # Setup authentication
360 279
    if username is not None:
361
      pwmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
362
      pwmgr.add_password(None, self._base_url, username, password)
363
      handlers.append(urllib2.HTTPBasicAuthHandler(pwmgr))
280
      if password is None:
281
        raise Error("Password not specified")
282
      curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
283
      curl.setopt(pycurl.USERPWD, str("%s:%s" % (username, password)))
364 284
    elif password:
365 285
      raise Error("Specified password without username")
366 286

  
367
    if ignore_proxy:
368
      handlers.append(urllib2.ProxyHandler({}))
369

  
370
    self._http = urllib2.build_opener(*handlers) # pylint: disable-msg=W0142
287
    # Call external configuration function
288
    if curl_config_fn:
289
      curl_config_fn(curl, logger)
371 290

  
372
    self._headers = {
373
      "Accept": HTTP_APP_JSON,
374
      "Content-type": HTTP_APP_JSON,
375
      "User-Agent": self.USER_AGENT,
376
      }
291
    self._curl = curl
377 292

  
378 293
  @staticmethod
379 294
  def _EncodeQuery(query):
......
427 342
    """
428 343
    assert path.startswith("/")
429 344

  
345
    curl = self._curl
346

  
430 347
    if content:
431 348
      encoded_content = self._json_encoder.encode(content)
432 349
    else:
433
      encoded_content = None
350
      encoded_content = ""
434 351

  
435 352
    # Build URL
436 353
    urlparts = [self._base_url, path]
......
440 357

  
441 358
    url = "".join(urlparts)
442 359

  
443
    self._logger.debug("Sending request %s %s to %s:%s"
444
                       " (headers=%r, content=%r)",
445
                       method, url, self._host, self._port, self._headers,
446
                       encoded_content)
360
    self._logger.debug("Sending request %s %s to %s:%s (content=%r)",
361
                       method, url, self._host, self._port, encoded_content)
362

  
363
    # Buffer for response
364
    encoded_resp_body = StringIO()
447 365

  
448
    req = _RapiRequest(method, url, self._headers, encoded_content)
366
    # Configure cURL
367
    curl.setopt(pycurl.CUSTOMREQUEST, str(method))
368
    curl.setopt(pycurl.URL, str(url))
369
    curl.setopt(pycurl.POSTFIELDS, str(encoded_content))
370
    curl.setopt(pycurl.WRITEFUNCTION, encoded_resp_body.write)
449 371

  
450 372
    try:
451
      resp = self._http.open(req)
452
      encoded_response_content = resp.read()
453
    except (OpenSSL.SSL.Error, OpenSSL.crypto.Error), err:
454
      raise CertificateError("SSL issue: %s (%r)" % (err, err))
455
    except urllib2.HTTPError, err:
456
      raise GanetiApiError(str(err), code=err.code)
457
    except urllib2.URLError, err:
458
      raise GanetiApiError(str(err))
459

  
460
    if encoded_response_content:
461
      response_content = simplejson.loads(encoded_response_content)
373
      # Send request and wait for response
374
      try:
375
        curl.perform()
376
      except pycurl.error, err:
377
        if err.args[0] in _CURL_SSL_CERT_ERRORS:
378
          raise CertificateError("SSL certificate error %s" % err)
379

  
380
        raise GanetiApiError(str(err))
381
    finally:
382
      # Reset settings to not keep references to large objects in memory
383
      # between requests
384
      curl.setopt(pycurl.POSTFIELDS, "")
385
      curl.setopt(pycurl.WRITEFUNCTION, lambda _: None)
386

  
387
    # Get HTTP response code
388
    http_code = curl.getinfo(pycurl.RESPONSE_CODE)
389

  
390
    # Was anything written to the response buffer?
391
    if encoded_resp_body.tell():
392
      response_content = simplejson.loads(encoded_resp_body.getvalue())
462 393
    else:
463 394
      response_content = None
464 395

  
465
    # TODO: Are there other status codes that are valid? (redirect?)
466
    if resp.code != HTTP_OK:
396
    if http_code != HTTP_OK:
467 397
      if isinstance(response_content, dict):
468 398
        msg = ("%s %s: %s" %
469 399
               (response_content["code"],
......
472 402
      else:
473 403
        msg = str(response_content)
474 404

  
475
      raise GanetiApiError(msg, code=resp.code)
405
      raise GanetiApiError(msg, code=http_code)
476 406

  
477 407
    return response_content
478 408

  

Also available in: Unified diff