Skip to content

Commit

Permalink
Merge pull request #7 from appliedsec/feature/full_ssl_opts
Browse files Browse the repository at this point in the history
deprecated individual ssl option parameters and added new ssl_opts parameter
  • Loading branch information
EliAndrewC committed Apr 5, 2016
2 parents bcea5a1 + c04f0c2 commit 4d70dcd
Show file tree
Hide file tree
Showing 14 changed files with 70 additions and 85 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ python:
- "2.7"
- "3.3"
- "3.4"
- "3.5"
install:
- "pip install -e ."
- "pip install pytest"
Expand Down
47 changes: 17 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ changes may be introduced.
- Currently only JSON-RPC is supported. We plan to add support for XML-RPC also.
- The connection pooling system should be considered alpha-quality. We would love feedback, but don't expect
a bug-free experience.
- We plan to support Python 3.x (no testing has been done, but using 2to3 may work with little modification)

## Installation

Expand Down Expand Up @@ -57,16 +56,13 @@ for connection pooling.
```python
from rpctools.jsonrpc import ServerProxy, Fault

proxy = ServerProxy(uri='https://example.com/jsonrpc',
ca_certs='/path/to/ca-bundle.crt', # PEM-encoded contatenated set of CA certificates
validate_cert_hostname=True # (This is also the default.)
)

proxy = ServerProxy('https://example.com/jsonrpc', ssl_opts={
'ca_certs': '/path/to/ca-bundle.crt', # PEM-encoded contatenated set of CA certificates
})
try:
proxy.someServerMethod(param1, param2)
except Fault:
# Fault instances are used to communicate server-side exceptions.
raise
raise # Fault instances are used to communicate server-side exceptions.
```

### ... with basic auth
Expand All @@ -76,33 +72,27 @@ The underlying httplib library supports providing basic auth in the URI:
```python
from rpctools.jsonrpc import ServerProxy, Fault

proxy = ServerProxy(uri='https://foo:[email protected]/jsonrpc',
ca_certs='/path/to/ca-bundle.crt'
)

proxy = ServerProxy('https://foo:[email protected]/jsonrpc')
try:
proxy.requiresAuth(param1, param2)
except Fault:
# Fault instances are used to communicate server-side exceptions.
raise
raise # Fault instances are used to communicate server-side exceptions.
```

### ... or client certs

```python
from rpctools.jsonrpc import ServerProxy, Fault

proxy = ServerProxy(uri='https://example.com/jsonrpc',
key_file='/path/to/client.key', # PEM-encoded
cert_file='/path/to/client.crt', # PEM-encoded
ca_certs='/path/to/ca-bundle.crt'
)

proxy = ServerProxy('https://example.com/jsonrpc', ssl_opts={
keyfile='/path/to/client.key', # PEM-encoded
certfile='/path/to/client.crt', # PEM-encoded
ca_certs='/path/to/ca-bundle.crt'
})
try:
proxy.someServerMethod(param1, param2)
except Fault:
# Fault instances are used to communicate server-side exceptions.
raise
raise # Fault instances are used to communicate server-side exceptions.
```

### ... connection pooling (ALPHA!)
Expand All @@ -113,13 +103,10 @@ to use the connection pool feature.
```python
from rpctools.jsonrpc import ServerProxy, Fault

proxy = ServerProxy(uri='http://example.com/jsonrpc',
pool_connections=True)

proxy = ServerProxy('http://example.com/jsonrpc', pool_connections=True)
for (param1, param2) in some_params_list:
try:
proxy.someServerMethod(param1, param2)
except Fault:
# Fault instances are used to communicate server-side exceptions.
raise
try:
proxy.someServerMethod(param1, param2)
except Fault:
raise # Fault instances are used to communicate server-side exceptions.
```
8 changes: 0 additions & 8 deletions docs/news.rst

This file was deleted.

2 changes: 1 addition & 1 deletion rpctools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
__license__ = """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
Expand Down
2 changes: 1 addition & 1 deletion rpctools/jsonrpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
__license__ = """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
Expand Down
36 changes: 22 additions & 14 deletions rpctools/jsonrpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import base64
import string
import warnings

from rpctools.six.moves.http_cookies import SimpleCookie
from rpctools.six.moves.urllib.parse import urlparse, unquote
Expand All @@ -29,6 +30,7 @@
See the License for the specific language governing permissions and
limitations under the License."""


# -----------------------------------------------------------------------------
# "INTERNAL" CLASSES
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -74,6 +76,7 @@ def __call__(self, *args, **kwargs):
def __repr__(self):
return '<%s name=%s>' % (self.__class__.__name__, self._name)


# -----------------------------------------------------------------------------
# PUBLIC CLASSES
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -107,14 +110,15 @@ class ServerProxy(object):
method_class = _Method

def __init__(self, uri, key_file=None, cert_file=None, ca_certs=None, validate_cert_hostname=True,
extra_headers=None, timeout=None, pool_connections=False):
extra_headers=None, timeout=None, pool_connections=False, ssl_opts=None):
"""
:param uri: The endpoint JSON-RPC server URL.
:param key_file: Secret key to use for ssl connection.
:param cert_file: Cert to send to server for ssl connection.
:param ca_certs: File containing concatenated list of certs to validate server cert against.
:param key_file: (Deprecated) Secret key to use for ssl connection.
:param cert_file: (Deprecated) Cert to send to server for ssl connection.
:param ca_certs: (Deprecated) File containing concatenated list of certs to validate server cert against.
:param extra_headers: Any additional headers to include with all requests.
:param pool_connections: Whether to use a thread-local connection pool for connections.
:param ssl_opts: Dictionary of options passed to ssl.wrap_socket
"""
self.logger = logging.getLogger('{0.__module__}.{0.__name__}'.format(self.__class__))
if extra_headers is None:
Expand All @@ -136,10 +140,13 @@ def __init__(self, uri, key_file=None, cert_file=None, ca_certs=None, validate_c
auth = auth.strip()
extra_headers.update({"Authorization": b"Basic " + auth})

self.key_file = key_file
self.cert_file = cert_file
self.ca_certs = ca_certs
self.validate_cert_hostname = validate_cert_hostname
self.ssl_opts = ssl_opts or {}
deprecated_params = {'keyfile': key_file, 'certfile': cert_file, 'ca_certs': ca_certs}
for opt, val in deprecated_params.items():
if val is not None:
warnings.warn('key_file, cert_file, and ca_certs arguments are deprecated; use ssl_opts argument instead', DeprecationWarning)
self.ssl_opts.setdefault(opt, val)

# TODO: This could probably be a little cleaner :)
if pool_connections:
Expand All @@ -149,13 +156,12 @@ def __init__(self, uri, key_file=None, cert_file=None, ca_certs=None, validate_c
self.transport = TLSConnectionPoolTransport(timeout=timeout)
else:
if self.type == "https":
self.transport = SafeTransport(key_file=self.key_file, cert_file=self.cert_file,
ca_certs=self.ca_certs, validate_cert_hostname=self.validate_cert_hostname)
self.transport = SafeTransport(timeout=timeout, ssl_opts=self.ssl_opts, validate_cert_hostname=self.validate_cert_hostname)
else:
self.transport = Transport(timeout=timeout)

self.extra_headers = extra_headers
self.id = 0 # Initialize our request ID (gets incremented for every request)
self.id = 0 # Initialize our request ID (gets incremented for every request)

def _request(self, methodname, params):
"""
Expand All @@ -174,7 +180,7 @@ def _request(self, methodname, params):
:raise ProtocolError: Re-raises exception if non-200 response received.
:raise Fault: If the response is an error message from remote application.
"""
self.id += 1 # Increment our "unique" identifier for every request.
self.id += 1 # Increment our "unique" identifier for every request.

data = dict(id=self.id, method=methodname, params=params)

Expand Down Expand Up @@ -204,14 +210,14 @@ def _request(self, methodname, params):
if not (('result' in decoded) or ('error' in decoded)):
# Include the decoded result (or part of it) in the error we raise
r = repr(decoded)
if len(r) > 256: # a hard-coded value to keep the exception message to a sane length
if len(r) > 256: # a hard-coded value to keep the exception message to a sane length
r = r[0:255] + '...'
raise ResponseError('Malformed JSON-RPC response to %s: %s' % (methodname, r))

if 'error' in decoded and decoded['error']:
raise Fault(decoded['error']['code'], decoded['error']['message'])

if not 'result' in decoded:
if 'result' not in decoded:
raise ResponseError('Malformed JSON-RPC response: %r' % decoded)

return decoded['result']
Expand Down Expand Up @@ -276,7 +282,7 @@ def _request(self, methodname, params):
:rtype: C{httplib.HTTPResponse}
:raise ProtocolError: Re-raises exception if non-200 response received.
"""
self.id += 1 # Increment our "unique" identifier for every request.
self.id += 1 # Increment our "unique" identifier for every request.

data = dict(id=self.id, method=methodname, params=params)

Expand All @@ -292,6 +298,7 @@ def _request(self, methodname, params):

return response


class CookieKeeperMixin(object):
"""
A L{ServerProxy} that supports receiving and setting cookies.
Expand Down Expand Up @@ -390,5 +397,6 @@ def _handle_response(self, response):
class CookieAwareServerProxy(CookieKeeperMixin, ServerProxy):
pass


class CookieAwareRawServerProxy(CookieKeeperMixin, RawServerProxy):
pass
5 changes: 5 additions & 0 deletions rpctools/jsonrpc/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ class JsonRpcError(Exception):
with the server, whereas faults are problems reported back from the remote server.
"""


class ConnectionError(JsonRpcError):
"""
Indicates an error at the TCP connection level.
This exception is raised when a socket.error is raised from the underlying httplib layer.
"""


class ProtocolError(JsonRpcError):
"""
Indicates an HTTP protocol error.
Expand All @@ -42,12 +44,14 @@ def __init__(self, url, errcode, errmsg, headers):
self.errcode = errcode
self.errmsg = errmsg
self.headers = headers

def __repr__(self):
return (
"<ProtocolError for %s: %s %s>" %
(self.url, self.errcode, self.errmsg)
)


class ResponseError(JsonRpcError):
"""
Indicates an invalid JSON response format.
Expand All @@ -57,6 +61,7 @@ class ResponseError(JsonRpcError):
"""
pass


class Fault(Exception):
"""
Represents an error that happened on the remote application that is being passed
Expand Down
4 changes: 3 additions & 1 deletion rpctools/jsonrpc/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
See the License for the specific language governing permissions and
limitations under the License."""


class Pool(ThreadLocal):
"""
A thread-local subclass that is responsible for managing a pool of connections,
Expand All @@ -29,6 +30,7 @@ def __init__(self):

pool = Pool()


class TLSConnectionPoolMixin(object):
"""
A mixin for Transport classes that attempts to use connections from a thread-local-storage
Expand Down Expand Up @@ -58,7 +60,7 @@ def connect(self, host):
Overrides method to return an existing connection from thread-local pool
instead of creating a new one.
"""
if not host in pool.connections:
if host not in pool.connections:
self.logger.debug("No connection in pool for %s, creating." % host)
conn = super(TLSConnectionPoolMixin, self).connect(host)
pool.connections[host] = conn
Expand Down
27 changes: 7 additions & 20 deletions rpctools/jsonrpc/ssl_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,35 +67,26 @@ def __str__(self):
'http://code.google.com/appengine/kb/general.html#rpcssl' %
(self.host, self.reason, self.cert))


class CertValidatingHTTPSConnection(httplib.HTTPConnection):
"""An HTTPConnection that connects over SSL and validates certificates."""

default_port = httplib.HTTPS_PORT

def __init__(self, host, port=None, key_file=None, cert_file=None,
ca_certs=None, validate_cert_hostname=True, strict=None, **kwargs):
def __init__(self, host, port=None, ssl_opts=None, validate_cert_hostname=True, strict=None, **kwargs):
"""Constructor.
Args:
host: The hostname. Can be in 'host:port' form.
port: The port. Defaults to 443.
key_file: A file containing the client's private key
cert_file: A file containing the client's certificates
ca_certs: A file contianing a set of concatenated certificate authority
certs for validating the server against.
ssl_opts: Options passed to ssl.wrap_socket
strict: When true, causes BadStatusLine to be raised if the status line
can't be parsed as a valid HTTP/1.0 or 1.1 status line.
"""
httplib.HTTPConnection.__init__(self, host, port, strict, **kwargs)
self.key_file = key_file
self.cert_file = cert_file
self.ca_certs = ca_certs
self.validate_cert_hostname = validate_cert_hostname

if self.ca_certs:
self.cert_reqs = ssl.CERT_REQUIRED
else:
self.cert_reqs = ssl.CERT_NONE
self.ssl_opts = ssl_opts or {}
self.ssl_opts.setdefault('cert_reqs', ssl.CERT_REQUIRED if self.ssl_opts.get('ca_certs') else ssl.CERT_NONE)

def _GetValidHostsForCert(self, cert):
"""Returns a list of valid host globs for an SSL certificate.
Expand Down Expand Up @@ -131,12 +122,8 @@ def connect(self):
"Connect to a host on a given (SSL) port."
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((self.host, self.port))
self.sock = ssl.wrap_socket(sock,
keyfile=self.key_file,
certfile=self.cert_file,
cert_reqs=self.cert_reqs,
ca_certs=self.ca_certs)
if (self.cert_reqs & ssl.CERT_REQUIRED) and self.validate_cert_hostname:
self.sock = ssl.wrap_socket(sock, **self.ssl_opts)
if (self.ssl_opts['cert_reqs'] & ssl.CERT_REQUIRED) and self.validate_cert_hostname:
cert = self.sock.getpeercert()
hostname = self.host.split(':', 0)[0]
if not self._ValidateCertificateHostname(cert, hostname):
Expand Down
Loading

0 comments on commit 4d70dcd

Please sign in to comment.