Skip to content

Commit 40c6d70

Browse files
committed
api: support of SSL protocol
The patch adds support for using SSL to encrypt the client-server communications [1]. 1. https://www.tarantool.io/en/enterprise_doc/security/#enterprise-iproto-encryption Part of #217
1 parent 46907c7 commit 40c6d70

17 files changed

+870
-88
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## Unreleased
88

99
### Added
10+
- SSL support (PR #220, #217).
1011

1112
### Changed
1213

Diff for: Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
.PHONY: test
22
test:
33
python setup.py test
4+
testdata:
5+
cd ./test/data/; ./generate.sh
46
coverage:
57
python -m coverage run -p --source=. setup.py test
68
cov-html:

Diff for: tarantool/connection.py

+111-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
import time
99
import errno
1010
import socket
11+
try:
12+
import ssl
13+
is_ssl_supported = True
14+
except ImportError:
15+
is_ssl_supported = False
16+
import sys
1117
import abc
1218

1319
import ctypes
@@ -19,7 +25,6 @@
1925

2026
import msgpack
2127

22-
import tarantool.error
2328
from tarantool.response import Response
2429
from tarantool.request import (
2530
Request,
@@ -44,6 +49,12 @@
4449
SOCKET_TIMEOUT,
4550
RECONNECT_MAX_ATTEMPTS,
4651
RECONNECT_DELAY,
52+
DEFAULT_TRANSPORT,
53+
SSL_TRANSPORT,
54+
DEFAULT_SSL_KEY_FILE,
55+
DEFAULT_SSL_CERT_FILE,
56+
DEFAULT_SSL_CA_FILE,
57+
DEFAULT_SSL_CIPHERS,
4758
REQUEST_TYPE_OK,
4859
REQUEST_TYPE_ERROR,
4960
IPROTO_GREETING_SIZE,
@@ -53,6 +64,7 @@
5364
from tarantool.error import (
5465
Error,
5566
NetworkError,
67+
SslError,
5668
DatabaseError,
5769
InterfaceError,
5870
ConfigurationError,
@@ -196,15 +208,28 @@ def __init__(self, host, port,
196208
encoding=ENCODING_DEFAULT,
197209
use_list=True,
198210
call_16=False,
199-
connection_timeout=CONNECTION_TIMEOUT):
211+
connection_timeout=CONNECTION_TIMEOUT,
212+
transport=DEFAULT_TRANSPORT,
213+
ssl_key_file=DEFAULT_SSL_KEY_FILE,
214+
ssl_cert_file=DEFAULT_SSL_CERT_FILE,
215+
ssl_ca_file=DEFAULT_SSL_CA_FILE,
216+
ssl_ciphers=DEFAULT_SSL_CIPHERS):
200217
'''
201218
Initialize a connection to the server.
202219
203220
:param str host: Server hostname or IP-address
204221
:param int port: Server port
205222
:param bool connect_now: if True (default) than __init__() actually
206-
creates network connection.
207-
if False than you have to call connect() manualy.
223+
creates network connection. if False than you have to call
224+
connect() manualy.
225+
:param str transport: It enables SSL encryption for a connection if set
226+
to ssl. At least Python 3.5 is required for SSL encryption.
227+
:param str ssl_key_file: A path to a private SSL key file.
228+
:param str ssl_cert_file: A path to an SSL certificate file.
229+
:param str ssl_ca_file: A path to a trusted certificate authorities
230+
(CA) file.
231+
:param str ssl_ciphers: A colon-separated (:) list of SSL cipher suites
232+
the connection can use.
208233
'''
209234

210235
if msgpack.version >= (1, 0, 0) and encoding not in (None, 'utf-8'):
@@ -237,6 +262,11 @@ def __init__(self, host, port,
237262
self.use_list = use_list
238263
self.call_16 = call_16
239264
self.connection_timeout = connection_timeout
265+
self.transport = transport
266+
self.ssl_key_file = ssl_key_file
267+
self.ssl_cert_file = ssl_cert_file
268+
self.ssl_ca_file = ssl_ca_file
269+
self.ssl_ciphers = ssl_ciphers
240270
if connect_now:
241271
self.connect()
242272

@@ -255,14 +285,15 @@ def is_closed(self):
255285
return self._socket is None
256286

257287
def connect_basic(self):
258-
if self.host == None:
288+
if self.host is None:
259289
self.connect_unix()
260290
else:
261291
self.connect_tcp()
262292

263293
def connect_tcp(self):
264294
'''
265295
Create connection to the host and port specified in __init__().
296+
266297
:raise: `NetworkError`
267298
'''
268299

@@ -282,6 +313,7 @@ def connect_tcp(self):
282313
def connect_unix(self):
283314
'''
284315
Create connection to the host and port specified in __init__().
316+
285317
:raise: `NetworkError`
286318
'''
287319

@@ -298,6 +330,73 @@ def connect_unix(self):
298330
self.connected = False
299331
raise NetworkError(e)
300332

333+
def wrap_socket_ssl(self):
334+
'''
335+
Wrap an existing socket with SSL socket.
336+
337+
:raise: SslError
338+
:raise: `ssl.SSLError`
339+
'''
340+
if not is_ssl_supported:
341+
raise SslError("SSL is unsupported by the python.")
342+
343+
ver = sys.version_info
344+
if ver[0] < 3 or (ver[0] == 3 and ver[1] < 5):
345+
raise SslError("SSL transport is supported only since " +
346+
"python 3.5")
347+
348+
if ((self.ssl_cert_file is None and self.ssl_key_file is not None)
349+
or (self.ssl_cert_file is not None and self.ssl_key_file is None)):
350+
raise SslError("ssl_cert_file and ssl_key_file should be both " +
351+
"configured or not")
352+
353+
try:
354+
if hasattr(ssl, 'TLSVersion'):
355+
# Since python 3.7
356+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
357+
# Reset to default OpenSSL values.
358+
context.check_hostname = False
359+
context.verify_mode = ssl.CERT_NONE
360+
# Require TLSv1.2, because other protocol versions don't seem
361+
# to support the GOST cipher.
362+
context.minimum_version = ssl.TLSVersion.TLSv1_2
363+
context.maximum_version = ssl.TLSVersion.TLSv1_2
364+
else:
365+
# Deprecated, but it works for python < 3.7
366+
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
367+
368+
if self.ssl_cert_file:
369+
# If the password argument is not specified and a password is
370+
# required, OpenSSL’s built-in password prompting mechanism
371+
# will be used to interactively prompt the user for a password.
372+
#
373+
# We should disable this behaviour, because a python
374+
# application that uses the connector unlikely assumes
375+
# interaction with a human + a Tarantool implementation does
376+
# not support this at least for now.
377+
def password_raise_error():
378+
raise SslError("a password for decrypting the private " +
379+
"key is unsupported")
380+
context.load_cert_chain(certfile=self.ssl_cert_file,
381+
keyfile=self.ssl_key_file,
382+
password=password_raise_error)
383+
384+
if self.ssl_ca_file:
385+
context.load_verify_locations(cafile=self.ssl_ca_file)
386+
context.verify_mode = ssl.CERT_REQUIRED
387+
# A Tarantool implementation does not check hostname. We don't
388+
# do that too. As a result we don't set here:
389+
# context.check_hostname = True
390+
391+
if self.ssl_ciphers:
392+
context.set_ciphers(self.ssl_ciphers)
393+
394+
self._socket = context.wrap_socket(self._socket)
395+
except SslError as e:
396+
raise e
397+
except Exception as e:
398+
raise SslError(e)
399+
301400
def handshake(self):
302401
greeting_buf = self._recv(IPROTO_GREETING_SIZE)
303402
greeting = greeting_decode(greeting_buf)
@@ -316,11 +415,16 @@ def connect(self):
316415
since it is called when you create an `Connection` instance.
317416
318417
:raise: `NetworkError`
418+
:raise: `SslError`
319419
'''
320420
try:
321421
self.connect_basic()
422+
if self.transport == SSL_TRANSPORT:
423+
self.wrap_socket_ssl()
322424
self.handshake()
323425
self.load_schema()
426+
except SslError as e:
427+
raise e
324428
except Exception as e:
325429
self.connected = False
326430
raise NetworkError(e)
@@ -447,6 +551,8 @@ def check(): # Check that connection is alive
447551
raise NetworkError(
448552
socket.error(last_errno, errno.errorcode[last_errno]))
449553
attempt += 1
554+
if self.transport == SSL_TRANSPORT:
555+
self.wrap_socket_ssl()
450556
self.handshake()
451557

452558
def _send_request(self, request):

Diff for: tarantool/connection_pool.py

+48-28
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,11 @@
2222
PoolTolopogyError,
2323
PoolTolopogyWarning,
2424
ConfigurationError,
25-
DatabaseError,
2625
NetworkError,
27-
NetworkWarning,
28-
tnt_strerror,
2926
warn
3027
)
3128
from tarantool.utils import ENCODING_DEFAULT
32-
from tarantool.mesh_connection import validate_address
29+
from tarantool.mesh_connection import prepare_address
3330

3431

3532
class Mode(Enum):
@@ -190,13 +187,15 @@ class ConnectionPool(ConnectionInterface):
190187
ConnectionPool API is the same as a plain Connection API.
191188
On each request, a connection is chosen to execute this request.
192189
Connection is selected based on request mode:
190+
193191
* Mode.ANY chooses any instance.
194192
* Mode.RW chooses an RW instance.
195193
* Mode.RO chooses an RO instance.
196194
* Mode.PREFER_RW chooses an RW instance, if possible, RO instance
197195
otherwise.
198196
* Mode.PREFER_RO chooses an RO instance, if possible, RW instance
199197
otherwise.
198+
200199
All requests that are guaranteed to write (insert, replace, delete,
201200
upsert, update) use RW mode by default. select uses ANY by default. You
202201
can set the mode explicitly. call, eval, execute and ping requests
@@ -218,39 +217,55 @@ def __init__(self,
218217
'''
219218
Initialize connections to the cluster of servers.
220219
221-
:param list addrs: List of {host: , port:} dictionaries,
222-
describing server addresses.
223-
:user str Username used to authenticate. User must be able
224-
to call box.info function. For example, to give grants to
225-
'guest' user, evaluate
226-
box.schema.func.create('box.info')
227-
box.schema.user.grant('guest', 'execute', 'function', 'box.info')
228-
on Tarantool instances.
220+
:param list addrs: List of
221+
222+
.. code-block:: python
223+
224+
{
225+
host: "str", # optional
226+
port: int or "str", # mandatory
227+
transport: "str", # optional
228+
ssl_key_file: "str", # optional
229+
ssl_cert_file: "str", # optional
230+
ssl_ca_file: "str", # optional
231+
ssl_ciphers: "str" # optional
232+
}
233+
234+
dictionaries, describing server addresses.
235+
See :func:`tarantool.Connection` parameters with same names.
236+
:param str user: Username used to authenticate. User must be able
237+
to call box.info function. For example, to give grants to
238+
'guest' user, evaluate
239+
box.schema.func.create('box.info')
240+
box.schema.user.grant('guest', 'execute', 'function', 'box.info')
241+
on Tarantool instances.
229242
:param int reconnect_max_attempts: Max attempts to reconnect
230-
for each connection in the pool. Be careful with reconnect
231-
parameters in ConnectionPool since every status refresh is
232-
also a request with reconnection. Default is 0 (fail after
233-
first attempt).
243+
for each connection in the pool. Be careful with reconnect
244+
parameters in ConnectionPool since every status refresh is
245+
also a request with reconnection. Default is 0 (fail after
246+
first attempt).
234247
:param float reconnect_delay: Time between reconnect
235-
attempts for each connection in the pool. Be careful with
236-
reconnect parameters in ConnectionPool since every status
237-
refresh is also a request with reconnection. Default is 0.
248+
attempts for each connection in the pool. Be careful with
249+
reconnect parameters in ConnectionPool since every status
250+
refresh is also a request with reconnection. Default is 0.
238251
:param StrategyInterface strategy_class: Class for choosing
239-
instance based on request mode. By default, round-robin
240-
strategy is used.
252+
instance based on request mode. By default, round-robin
253+
strategy is used.
241254
:param int refresh_delay: Minimal time between RW/RO status
242-
refreshes.
255+
refreshes.
243256
'''
244257

245258
if not isinstance(addrs, list) or len(addrs) == 0:
246259
raise ConfigurationError("addrs must be non-empty list")
247260

248-
# Verify addresses.
261+
# Prepare addresses for usage.
262+
new_addrs = []
249263
for addr in addrs:
250-
ok, msg = validate_address(addr)
251-
if not ok:
264+
new_addr, msg = prepare_address(addr)
265+
if not new_addr:
252266
raise ConfigurationError(msg)
253-
self.addrs = addrs
267+
new_addrs.append(new_addr)
268+
self.addrs = new_addrs
254269

255270
# Create connections
256271
self.pool = {}
@@ -272,7 +287,12 @@ def __init__(self,
272287
connect_now=False, # Connect in ConnectionPool.connect()
273288
encoding=encoding,
274289
call_16=call_16,
275-
connection_timeout=connection_timeout)
290+
connection_timeout=connection_timeout,
291+
transport=addr['transport'],
292+
ssl_key_file=addr['ssl_key_file'],
293+
ssl_cert_file=addr['ssl_cert_file'],
294+
ssl_ca_file=addr['ssl_ca_file'],
295+
ssl_ciphers=addr['ssl_ciphers'])
276296
)
277297

278298
if connect_now:
@@ -464,7 +484,7 @@ def ping(self, *, mode=None, **kwargs):
464484
def select(self, space_name, key, *, mode=Mode.ANY, **kwargs):
465485
'''
466486
:param tarantool.Mode mode: Request mode (default is
467-
ANY).
487+
ANY).
468488
'''
469489

470490
return self._send(mode, 'select', space_name, key, **kwargs)

Diff for: tarantool/const.py

+12
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@
9494
RECONNECT_MAX_ATTEMPTS = 10
9595
# Default delay between attempts to reconnect (seconds)
9696
RECONNECT_DELAY = 0.1
97+
# Default value for transport
98+
DEFAULT_TRANSPORT = ""
99+
# Value for SSL transport
100+
SSL_TRANSPORT = "ssl"
101+
# Default value for a path to SSL key file
102+
DEFAULT_SSL_KEY_FILE = None
103+
# Default value for a path to SSL certificate file
104+
DEFAULT_SSL_CERT_FILE = None
105+
# Default value for a path to SSL certificatea uthorities file
106+
DEFAULT_SSL_CA_FILE = None
107+
# Default value for list of SSL ciphers
108+
DEFAULT_SSL_CIPHERS = None
97109
# Default cluster nodes list refresh interval (seconds)
98110
CLUSTER_DISCOVERY_DELAY = 60
99111
# Default cluster nodes state refresh interval (seconds)

0 commit comments

Comments
 (0)