Skip to content

Commit f8ded3d

Browse files
committed
Fix multiple issues in TLS session caching implementation
This commit addresses several issues identified in the TLS session caching feature: 1. SNI endpoint cache key collision: Added tls_session_cache_key property to EndPoint classes. SniEndPoint now includes server_name in the cache key to prevent collisions when multiple SNI endpoints use the same proxy. 2. TLS session caching for eventlet/twisted reactors: Added PyOpenSSL-based session caching support in EventletConnection and TwistedConnection (via _SSLCreator) to enable TLS session resumption for these reactors. 3. Missing hasattr check: Added hasattr check for session_reused attribute before accessing it, as not all SSL socket implementations have it. 4. Automatic cleanup of expired sessions: Added opportunistic cleanup in set_session() that runs every 100 operations to prevent memory accumulation from expired sessions. 5. Log message formatting: Changed log messages to use endpoint's __str__ method instead of manually formatting address:port, which handles UnixSocketEndPoint (port=None) correctly. 6. Custom TLSSessionCache support: Updated Cluster to accept either TLSSessionCacheOptions or a TLSSessionCache instance directly, allowing users to provide custom cache implementations.
1 parent a244378 commit f8ded3d

9 files changed

Lines changed: 604 additions & 32 deletions

File tree

cassandra/cluster.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -908,16 +908,19 @@ def default_retry_policy(self, policy):
908908

909909
tls_session_cache_options = None
910910
"""
911-
Advanced TLS session cache configuration. Set to an instance of
912-
:class:`~cassandra.tls.TLSSessionCacheOptions` for fine-grained control over
913-
session caching behavior (e.g., cache_by_host_only option).
911+
Advanced TLS session cache configuration. Can be set to:
912+
913+
- An instance of :class:`~cassandra.tls.TLSSessionCacheOptions` for
914+
fine-grained control over session caching behavior (e.g., cache_by_host_only option).
915+
- An instance of :class:`~cassandra.tls.TLSSessionCache` (or a custom subclass)
916+
for complete control over session caching implementation.
914917
915918
If None (default), a cache is created using :attr:`~.tls_session_cache_size`
916919
and :attr:`~.tls_session_cache_ttl` when SSL/TLS is enabled.
917920
918921
This option takes precedence over the individual tls_session_cache_* parameters.
919922
920-
Example::
923+
Example with options::
921924
922925
from cassandra.tls import TLSSessionCacheOptions
923926
@@ -929,6 +932,16 @@ def default_retry_policy(self, policy):
929932
)
930933
cluster = Cluster(ssl_context=ssl_context, tls_session_cache_options=options)
931934
935+
Example with custom cache::
936+
937+
from cassandra.tls import TLSSessionCache
938+
939+
class MyCustomCache(TLSSessionCache):
940+
# Custom implementation
941+
pass
942+
943+
cluster = Cluster(ssl_context=ssl_context, tls_session_cache_options=MyCustomCache())
944+
932945
.. versionadded:: 3.30.0
933946
"""
934947

@@ -1489,19 +1502,24 @@ def __init__(self,
14891502
# Initialize TLS session cache if SSL is enabled and caching is enabled
14901503
self._tls_session_cache = None
14911504
if (ssl_context or ssl_options) and tls_session_cache_enabled:
1492-
from cassandra.tls import TLSSessionCacheOptions
1505+
from cassandra.tls import TLSSessionCache, TLSSessionCacheOptions
14931506

1494-
# Use provided options object or create one from individual parameters
14951507
if tls_session_cache_options is not None:
1496-
cache_options = tls_session_cache_options
1508+
# Check if it's a TLSSessionCache instance (use directly)
1509+
# or TLSSessionCacheOptions (use create_cache())
1510+
if isinstance(tls_session_cache_options, TLSSessionCache):
1511+
self._tls_session_cache = tls_session_cache_options
1512+
else:
1513+
# Assume it's TLSSessionCacheOptions
1514+
self._tls_session_cache = tls_session_cache_options.create_cache()
14971515
else:
1516+
# Create default cache from individual parameters
14981517
cache_options = TLSSessionCacheOptions(
14991518
max_size=tls_session_cache_size,
15001519
ttl=tls_session_cache_ttl,
15011520
cache_by_host_only=False
15021521
)
1503-
1504-
self._tls_session_cache = cache_options.create_cache()
1522+
self._tls_session_cache = cache_options.create_cache()
15051523

15061524
self.sockopts = sockopts
15071525
self.cql_version = cql_version

cassandra/connection.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ def socket_family(self):
161161
"""
162162
return socket.AF_UNSPEC
163163

164+
@property
165+
def tls_session_cache_key(self):
166+
"""
167+
Returns the cache key components for TLS session caching.
168+
This is a tuple that uniquely identifies this endpoint for TLS session purposes.
169+
Subclasses may override this to include additional components (e.g., SNI server name).
170+
"""
171+
return (self.address, self.port)
172+
164173
def resolve(self):
165174
"""
166175
Resolve the endpoint to an address/port. This is called
@@ -275,6 +284,14 @@ def port(self):
275284
def ssl_options(self):
276285
return self._ssl_options
277286

287+
@property
288+
def tls_session_cache_key(self):
289+
"""
290+
Returns the cache key including server_name for SNI endpoints.
291+
This prevents cache collisions when multiple SNI endpoints use the same proxy.
292+
"""
293+
return (self.address, self.port, self._server_name)
294+
278295
def resolve(self):
279296
try:
280297
resolved_addresses = socket.getaddrinfo(self._proxy_address, self._port,
@@ -349,6 +366,14 @@ def port(self):
349366
def socket_family(self):
350367
return socket.AF_UNIX
351368

369+
@property
370+
def tls_session_cache_key(self):
371+
"""
372+
Returns the cache key for Unix socket endpoints.
373+
Since Unix sockets don't have a port, only the path is used.
374+
"""
375+
return (self._unix_socket_path,)
376+
352377
def resolve(self):
353378
return self.address, None
354379

@@ -922,8 +947,7 @@ def _wrap_socket_from_context(self):
922947
cached_session = self.tls_session_cache.get_session(self.endpoint)
923948
if cached_session:
924949
opts['session'] = cached_session
925-
log.debug("Using cached TLS session for %s:%s",
926-
self.endpoint.address, self.endpoint.port)
950+
log.debug("Using cached TLS session for %s", self.endpoint)
927951

928952
ssl_socket = self.ssl_context.wrap_socket(self._socket, **opts)
929953

@@ -991,9 +1015,8 @@ def _connect_socket(self):
9911015
if self.tls_session_cache and self.ssl_context and hasattr(self._socket, 'session'):
9921016
if self._socket.session:
9931017
self.tls_session_cache.set_session(self.endpoint, self._socket.session)
994-
if self._socket.session_reused:
995-
log.debug("TLS session was reused for %s:%s",
996-
self.endpoint.address, self.endpoint.port)
1018+
if hasattr(self._socket, 'session_reused') and self._socket.session_reused:
1019+
log.debug("TLS session was reused for %s", self.endpoint)
9971020

9981021
sockerr = None
9991022
break

cassandra/io/eventletreactor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,27 @@ def _wrap_socket_from_context(self):
109109
# This is necessary for SNI
110110
self._socket.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
111111

112+
# Apply cached TLS session for resumption (PyOpenSSL)
113+
if self.tls_session_cache:
114+
cached_session = self.tls_session_cache.get_session(self.endpoint)
115+
if cached_session:
116+
self._socket.set_session(cached_session)
117+
log.debug("Using cached TLS session for %s", self.endpoint)
118+
112119
def _initiate_connection(self, sockaddr):
113120
if self.uses_legacy_ssl_options:
114121
super(EventletConnection, self)._initiate_connection(sockaddr)
115122
else:
116123
self._socket.connect(sockaddr)
117124
if self.ssl_context or self.ssl_options:
118125
self._socket.do_handshake()
126+
# Store TLS session after successful handshake (PyOpenSSL)
127+
if self.tls_session_cache:
128+
session = self._socket.get_session()
129+
if session:
130+
self.tls_session_cache.set_session(self.endpoint, session)
131+
if self._socket.session_reused():
132+
log.debug("TLS session was reused for %s", self.endpoint)
119133

120134
def _match_hostname(self):
121135
if self.uses_legacy_ssl_options:

cassandra/io/twistedreactor.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,12 @@ def _on_loop_timer(self):
139139

140140
@implementer(IOpenSSLClientConnectionCreator)
141141
class _SSLCreator(object):
142-
def __init__(self, endpoint, ssl_context, ssl_options, check_hostname, timeout):
142+
def __init__(self, endpoint, ssl_context, ssl_options, check_hostname, timeout, tls_session_cache=None):
143143
self.endpoint = endpoint
144144
self.ssl_options = ssl_options
145145
self.check_hostname = check_hostname
146146
self.timeout = timeout
147+
self.tls_session_cache = tls_session_cache
147148

148149
if ssl_context:
149150
self.context = ssl_context
@@ -171,11 +172,27 @@ def info_callback(self, connection, where, ret):
171172
transport = connection.get_app_data()
172173
transport.failVerification(Failure(ConnectionException("Hostname verification failed", self.endpoint)))
173174

175+
# Store TLS session after successful handshake (PyOpenSSL)
176+
if self.tls_session_cache:
177+
session = connection.get_session()
178+
if session:
179+
self.tls_session_cache.set_session(self.endpoint, session)
180+
if connection.session_reused():
181+
log.debug("TLS session was reused for %s", self.endpoint)
182+
174183
def clientConnectionForTLS(self, tlsProtocol):
175184
connection = SSL.Connection(self.context, None)
176185
connection.set_app_data(tlsProtocol)
177186
if self.ssl_options and "server_hostname" in self.ssl_options:
178187
connection.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
188+
189+
# Apply cached TLS session for resumption (PyOpenSSL)
190+
if self.tls_session_cache:
191+
cached_session = self.tls_session_cache.get_session(self.endpoint)
192+
if cached_session:
193+
connection.set_session(cached_session)
194+
log.debug("Using cached TLS session for %s", self.endpoint)
195+
179196
return connection
180197

181198

@@ -241,6 +258,7 @@ def add_connection(self):
241258
self.ssl_options,
242259
self._check_hostname,
243260
self.connect_timeout,
261+
tls_session_cache=self.tls_session_cache,
244262
)
245263

246264
endpoint = SSL4ClientEndpoint(

cassandra/tls.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ class DefaultTLSSessionCache(TLSSessionCache):
9191
version-specific checks are needed.
9292
"""
9393

94+
# Cleanup expired sessions every N set_session calls
95+
_EXPIRY_CLEANUP_INTERVAL = 100
96+
9497
def __init__(self, max_size=100, ttl=3600, cache_by_host_only=False):
9598
"""
9699
Initialize the TLS session cache.
97-
100+
98101
Args:
99102
max_size: Maximum number of sessions to cache (default: 100)
100103
ttl: Time-to-live for cached sessions in seconds (default: 3600)
@@ -106,13 +109,22 @@ def __init__(self, max_size=100, ttl=3600, cache_by_host_only=False):
106109
self._max_size = max_size
107110
self._ttl = ttl
108111
self._cache_by_host_only = cache_by_host_only
112+
self._operation_count = 0 # Counter for opportunistic cleanup
109113

110114
def _make_key(self, endpoint):
111-
"""Create a cache key from endpoint."""
115+
"""
116+
Create a cache key from endpoint.
117+
118+
Uses the endpoint's tls_session_cache_key property which returns
119+
appropriate components for each endpoint type (e.g., includes
120+
server_name for SNI endpoints to prevent cache collisions).
121+
"""
122+
key = endpoint.tls_session_cache_key
112123
if self._cache_by_host_only:
113-
return (endpoint.address,)
124+
# When caching by host only, use just the first component (address/path)
125+
return (key[0],)
114126
else:
115-
return (endpoint.address, endpoint.port)
127+
return key
116128

117129
def get_session(self, endpoint):
118130
"""
@@ -143,41 +155,52 @@ def get_session(self, endpoint):
143155
def set_session(self, endpoint, session):
144156
"""
145157
Store a TLS session for the given endpoint.
146-
158+
147159
Args:
148160
endpoint: The EndPoint object representing the connection target
149161
session: The ssl.SSLSession object to cache
150162
"""
151163
if session is None:
152164
return
153-
165+
154166
key = self._make_key(endpoint)
155167
current_time = time.time()
156-
168+
157169
with self._lock:
170+
# Opportunistically clean up expired sessions periodically
171+
self._operation_count += 1
172+
if self._operation_count >= self._EXPIRY_CLEANUP_INTERVAL:
173+
self._operation_count = 0
174+
self._clear_expired_unlocked(current_time)
175+
158176
# If key already exists, just update it
159177
if key in self._sessions:
160178
self._sessions[key] = _SessionCacheEntry(session, current_time)
161179
self._sessions.move_to_end(key)
162180
return
163-
181+
164182
# If cache is at max size, remove least recently used entry (first item)
165183
if len(self._sessions) >= self._max_size:
166184
self._sessions.popitem(last=False)
167-
185+
168186
# Store session with creation time
169187
self._sessions[key] = _SessionCacheEntry(session, current_time)
170188

189+
def _clear_expired_unlocked(self, current_time=None):
190+
"""Remove all expired sessions (must be called with lock held)."""
191+
if current_time is None:
192+
current_time = time.time()
193+
expired_keys = [
194+
key for key, entry in self._sessions.items()
195+
if current_time - entry.timestamp > self._ttl
196+
]
197+
for key in expired_keys:
198+
del self._sessions[key]
199+
171200
def clear_expired(self):
172201
"""Remove all expired sessions from the cache."""
173-
current_time = time.time()
174202
with self._lock:
175-
expired_keys = [
176-
key for key, entry in self._sessions.items()
177-
if current_time - entry.timestamp > self._ttl
178-
]
179-
for key in expired_keys:
180-
del self._sessions[key]
203+
self._clear_expired_unlocked()
181204

182205
def clear(self):
183206
"""Clear all sessions from the cache."""

0 commit comments

Comments
 (0)