Skip to content

Commit 7281340

Browse files
Add SSLSessionCache to Cluster for TLS ticket caching
Add ssl_session_cache to Cluster so all managed connections share a single TLS session store. - Auto-create SSLSessionCache when ssl_context or ssl_options are set; pass ssl_session_cache=None to opt out - Accept explicit ssl_session_cache to allow a custom instance - Warn when Twisted/Eventlet connection classes are used (pyOpenSSL does not support stdlib ssl session resumption; cache has no effect) - Pass the cache through connection_factory kwargs to each Connection Includes unit tests for auto-creation, opt-out, custom cache injection, factory propagation, and pyOpenSSL warnings.
1 parent 449a846 commit 7281340

File tree

2 files changed

+142
-2
lines changed

2 files changed

+142
-2
lines changed

cassandra/cluster.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
from cassandra.connection import (ClientRoutesEndPointFactory, ConnectionException, ConnectionShutdown,
5353
ConnectionHeartbeat, ProtocolVersionUnsupported,
5454
EndPoint, DefaultEndPoint, DefaultEndPointFactory,
55-
SniEndPointFactory, ConnectionBusy, locally_supported_compressions)
55+
SniEndPointFactory, ConnectionBusy, locally_supported_compressions,
56+
SSLSessionCache)
5657
from cassandra.cqltypes import UserType
5758
import cassandra.cqltypes as types
5859
from cassandra.encoder import Encoder
@@ -876,6 +877,26 @@ def default_retry_policy(self, policy):
876877
.. versionadded:: 3.17.0
877878
"""
878879

880+
ssl_session_cache = None
881+
"""
882+
An optional :class:`.connection.SSLSessionCache` instance used to enable TLS
883+
session resumption (via session tickets or PSK) for all connections managed
884+
by this cluster.
885+
886+
When :attr:`~Cluster.ssl_context` or :attr:`~Cluster.ssl_options` are set,
887+
a cache is created automatically so that reconnections to the same host can
888+
skip the full TLS handshake. Set this to :const:`None` explicitly to
889+
disable session caching.
890+
891+
Note: TLS 1.2 sessions are cached immediately after connect. TLS 1.3
892+
sessions are cached after the CQL handshake completes (Ready / AuthSuccess),
893+
because session tickets are sent asynchronously by the server.
894+
895+
Note: only the stdlib ``ssl`` reactor paths are supported (asyncore, libev,
896+
gevent, asyncio). Twisted and Eventlet connections use pyOpenSSL and are
897+
not covered by this cache.
898+
"""
899+
879900
sockopts = None
880901
"""
881902
An optional list of tuples which will be used as arguments to
@@ -1217,7 +1238,8 @@ def __init__(self,
12171238
metadata_request_timeout: Optional[float] = None,
12181239
column_encryption_policy=None,
12191240
application_info:Optional[ApplicationInfoBase]=None,
1220-
client_routes_config:Optional[ClientRoutesConfig]=None
1241+
client_routes_config:Optional[ClientRoutesConfig]=None,
1242+
ssl_session_cache=_NOT_SET
12211243
):
12221244
"""
12231245
``executor_threads`` defines the number of threads in a pool for handling asynchronous tasks such as
@@ -1461,6 +1483,30 @@ def __init__(self,
14611483

14621484
self.ssl_options = ssl_options
14631485
self.ssl_context = ssl_context
1486+
1487+
# Auto-create a session cache when TLS is enabled, unless the caller
1488+
# explicitly passed ssl_session_cache (including None to opt out).
1489+
if ssl_session_cache is _NOT_SET:
1490+
if ssl_context is not None or ssl_options is not None:
1491+
self.ssl_session_cache = SSLSessionCache()
1492+
else:
1493+
self.ssl_session_cache = None
1494+
else:
1495+
self.ssl_session_cache = ssl_session_cache
1496+
1497+
# Warn when the session cache won't be used because the connection
1498+
# class uses pyOpenSSL instead of the stdlib ssl module.
1499+
if self.ssl_session_cache is not None:
1500+
uses_twisted = TwistedConnection and issubclass(self.connection_class, TwistedConnection)
1501+
uses_eventlet = EventletConnection and issubclass(self.connection_class, EventletConnection)
1502+
if uses_twisted or uses_eventlet:
1503+
log.warning(
1504+
"ssl_session_cache is set but the connection class %s uses "
1505+
"pyOpenSSL, which does not support stdlib ssl session "
1506+
"resumption. The cache will have no effect.",
1507+
self.connection_class.__name__,
1508+
)
1509+
14641510
self.sockopts = sockopts
14651511
self.cql_version = cql_version
14661512
self.max_schema_agreement_wait = max_schema_agreement_wait
@@ -1706,6 +1752,7 @@ def _make_connection_kwargs(self, endpoint, kwargs_dict):
17061752
kwargs_dict.setdefault('sockopts', self.sockopts)
17071753
kwargs_dict.setdefault('ssl_options', self.ssl_options)
17081754
kwargs_dict.setdefault('ssl_context', self.ssl_context)
1755+
kwargs_dict.setdefault('ssl_session_cache', self.ssl_session_cache)
17091756
kwargs_dict.setdefault('cql_version', self.cql_version)
17101757
kwargs_dict.setdefault('protocol_version', self.protocol_version)
17111758
kwargs_dict.setdefault('user_type_map', self._user_types)

tests/unit/test_cluster.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion
2424
from cassandra.cluster import _Scheduler, Session, Cluster, default_lbp_factory, \
2525
ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT
26+
from cassandra.connection import SSLSessionCache
2627
from cassandra.pool import Host
2728
from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy
2829
from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory
@@ -634,3 +635,95 @@ def test_no_warning_adding_lbp_ep_to_cluster_with_contact_points(self):
634635
)
635636

636637
patched_logger.warning.assert_not_called()
638+
639+
640+
class TestSSLSessionCacheAutoCreation(unittest.TestCase):
641+
642+
def test_cache_created_when_ssl_context_set(self):
643+
import ssl
644+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
645+
ctx.check_hostname = False
646+
ctx.verify_mode = ssl.CERT_NONE
647+
cluster = Cluster(contact_points=['127.0.0.1'], ssl_context=ctx)
648+
assert isinstance(cluster.ssl_session_cache, SSLSessionCache)
649+
650+
def test_cache_created_when_ssl_options_set(self):
651+
cluster = Cluster(contact_points=['127.0.0.1'], ssl_options={'ca_certs': '/dev/null'})
652+
assert isinstance(cluster.ssl_session_cache, SSLSessionCache)
653+
654+
def test_no_cache_when_tls_not_enabled(self):
655+
cluster = Cluster(contact_points=['127.0.0.1'])
656+
assert cluster.ssl_session_cache is None
657+
658+
def test_explicit_none_disables_cache(self):
659+
import ssl
660+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
661+
ctx.check_hostname = False
662+
ctx.verify_mode = ssl.CERT_NONE
663+
cluster = Cluster(contact_points=['127.0.0.1'], ssl_context=ctx,
664+
ssl_session_cache=None)
665+
assert cluster.ssl_session_cache is None
666+
667+
def test_explicit_custom_cache_used(self):
668+
import ssl
669+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
670+
ctx.check_hostname = False
671+
ctx.verify_mode = ssl.CERT_NONE
672+
custom = SSLSessionCache()
673+
cluster = Cluster(contact_points=['127.0.0.1'], ssl_context=ctx,
674+
ssl_session_cache=custom)
675+
assert cluster.ssl_session_cache is custom
676+
677+
def test_cache_passed_to_connection_factory(self):
678+
import ssl
679+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
680+
ctx.check_hostname = False
681+
ctx.verify_mode = ssl.CERT_NONE
682+
endpoint = Mock(address='127.0.0.1')
683+
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory:
684+
cluster = Cluster(contact_points=['127.0.0.1'], ssl_context=ctx)
685+
cluster.connection_factory(endpoint)
686+
687+
assert factory.call_args.kwargs['ssl_session_cache'] is cluster.ssl_session_cache
688+
689+
def test_warning_for_eventlet_connection_class(self):
690+
"""A warning is logged when ssl_session_cache is set with EventletConnection."""
691+
import ssl
692+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
693+
ctx.check_hostname = False
694+
ctx.verify_mode = ssl.CERT_NONE
695+
696+
# Create a real class so issubclass() works throughout Cluster.__init__
697+
from cassandra.connection import Connection as BaseConn
698+
class FakeEventletConnection(BaseConn):
699+
pass
700+
701+
with patch('cassandra.cluster.EventletConnection', FakeEventletConnection, create=True), \
702+
patch('cassandra.cluster.log') as patched_logger:
703+
Cluster(contact_points=['127.0.0.1'], ssl_context=ctx,
704+
connection_class=FakeEventletConnection)
705+
706+
# At least one warning about pyOpenSSL
707+
warning_calls = [c for c in patched_logger.warning.call_args_list
708+
if 'pyOpenSSL' in str(c)]
709+
assert len(warning_calls) == 1
710+
711+
def test_warning_for_twisted_connection_class(self):
712+
"""A warning is logged when ssl_session_cache is set with TwistedConnection."""
713+
import ssl
714+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
715+
ctx.check_hostname = False
716+
ctx.verify_mode = ssl.CERT_NONE
717+
718+
from cassandra.connection import Connection as BaseConn
719+
class FakeTwistedConnection(BaseConn):
720+
pass
721+
722+
with patch('cassandra.cluster.TwistedConnection', FakeTwistedConnection, create=True), \
723+
patch('cassandra.cluster.log') as patched_logger:
724+
Cluster(contact_points=['127.0.0.1'], ssl_context=ctx,
725+
connection_class=FakeTwistedConnection)
726+
727+
warning_calls = [c for c in patched_logger.warning.call_args_list
728+
if 'pyOpenSSL' in str(c)]
729+
assert len(warning_calls) == 1

0 commit comments

Comments
 (0)