Skip to content

Commit 655c712

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 655c712

17 files changed

+795
-61
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

+86-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,
@@ -196,15 +207,28 @@ def __init__(self, host, port,
196207
encoding=ENCODING_DEFAULT,
197208
use_list=True,
198209
call_16=False,
199-
connection_timeout=CONNECTION_TIMEOUT):
210+
connection_timeout=CONNECTION_TIMEOUT,
211+
transport=DEFAULT_TRANSPORT,
212+
ssl_key_file=DEFAULT_SSL_KEY_FILE,
213+
ssl_cert_file=DEFAULT_SSL_CERT_FILE,
214+
ssl_ca_file=DEFAULT_SSL_CA_FILE,
215+
ssl_ciphers=DEFAULT_SSL_CIPHERS):
200216
'''
201217
Initialize a connection to the server.
202218
203219
:param str host: Server hostname or IP-address
204220
:param int port: Server port
205221
:param bool connect_now: if True (default) than __init__() actually
206-
creates network connection.
207-
if False than you have to call connect() manualy.
222+
creates network connection. if False than you have to call
223+
connect() manualy.
224+
:param str transport: It enables SSL encryption for a connection if set
225+
to ssl. At least Python 3.5 is required for SSL encryption.
226+
:param str ssl_key_file: A path to a private SSL key file.
227+
:param str ssl_cert_file: A path to an SSL certificate file.
228+
:param str ssl_ca_file: A path to a trusted certificate authorities
229+
(CA) file.
230+
:param str ssl_ciphers: A colon-separated (:) list of SSL cipher suites
231+
the connection can use.
208232
'''
209233

210234
if msgpack.version >= (1, 0, 0) and encoding not in (None, 'utf-8'):
@@ -237,6 +261,11 @@ def __init__(self, host, port,
237261
self.use_list = use_list
238262
self.call_16 = call_16
239263
self.connection_timeout = connection_timeout
264+
self.transport = transport
265+
self.ssl_key_file = ssl_key_file
266+
self.ssl_cert_file = ssl_cert_file
267+
self.ssl_ca_file = ssl_ca_file
268+
self.ssl_ciphers = ssl_ciphers
240269
if connect_now:
241270
self.connect()
242271

@@ -255,14 +284,15 @@ def is_closed(self):
255284
return self._socket is None
256285

257286
def connect_basic(self):
258-
if self.host == None:
287+
if self.host is None:
259288
self.connect_unix()
260289
else:
261290
self.connect_tcp()
262291

263292
def connect_tcp(self):
264293
'''
265294
Create connection to the host and port specified in __init__().
295+
266296
:raise: `NetworkError`
267297
'''
268298

@@ -282,6 +312,7 @@ def connect_tcp(self):
282312
def connect_unix(self):
283313
'''
284314
Create connection to the host and port specified in __init__().
315+
285316
:raise: `NetworkError`
286317
'''
287318

@@ -298,6 +329,52 @@ def connect_unix(self):
298329
self.connected = False
299330
raise NetworkError(e)
300331

332+
def wrap_socket_ssl(self):
333+
'''
334+
Wrap an existing socket with SSL socket.
335+
336+
:raise: NetworkError
337+
:raise: `ssl.SSLError`
338+
'''
339+
if not is_ssl_supported:
340+
raise NetworkError("SSL is unsupported by the python.")
341+
342+
ver = sys.version_info
343+
if ver[0] < 3 or (ver[0] == 3 and ver[1] < 5):
344+
raise NetworkError("SSL transport is supported only since " +
345+
"python 3.5")
346+
347+
if hasattr(ssl, 'TLSVersion'):
348+
# Since python 3.7
349+
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
350+
# Require TLSv1.2, because other protocol versions don't seem to
351+
# support the GOST cipher.
352+
context.minimum_version = ssl.TLSVersion.TLSv1_2
353+
context.maximum_version = ssl.TLSVersion.TLSv1_2
354+
else:
355+
# Deprecated, but it works for python < 3.7
356+
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
357+
358+
if self.ssl_cert_file:
359+
def password_raise_error():
360+
raise NetworkError("a password for decrypting the private" +
361+
"key is unsupported")
362+
context.load_cert_chain(certfile=self.ssl_cert_file,
363+
keyfile=self.ssl_key_file,
364+
password=password_raise_error)
365+
366+
if self.ssl_ca_file:
367+
context.load_verify_locations(cafile=self.ssl_ca_file)
368+
context.verify_mode = ssl.CERT_REQUIRED
369+
if self.host:
370+
context.check_hostname = True
371+
372+
if self.ssl_ciphers:
373+
context.set_ciphers(self.ssl_ciphers)
374+
375+
self._socket = context.wrap_socket(self._socket,
376+
server_hostname=self.host)
377+
301378
def handshake(self):
302379
greeting_buf = self._recv(IPROTO_GREETING_SIZE)
303380
greeting = greeting_decode(greeting_buf)
@@ -319,6 +396,8 @@ def connect(self):
319396
'''
320397
try:
321398
self.connect_basic()
399+
if self.transport == SSL_TRANSPORT:
400+
self.wrap_socket_ssl()
322401
self.handshake()
323402
self.load_schema()
324403
except Exception as e:
@@ -447,6 +526,8 @@ def check(): # Check that connection is alive
447526
raise NetworkError(
448527
socket.error(last_errno, errno.errorcode[last_errno]))
449528
attempt += 1
529+
if self.transport == SSL_TRANSPORT:
530+
self.wrap_socket_ssl()
450531
self.handshake()
451532

452533
def _send_request(self, request):

Diff for: tarantool/connection_pool.py

+41-26
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,36 +217,47 @@ 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+
{ host: "str",
225+
port: int,
226+
transport: "str",
227+
ssl_key_file: "str",
228+
ssl_cert_file: "str",
229+
ssl_ca_file: "str",
230+
ssl_ciphers: "str" }
231+
232+
dictionaries, describing server addresses.
233+
:param str user: Username used to authenticate. User must be able
234+
to call box.info function. For example, to give grants to
235+
'guest' user, evaluate
236+
box.schema.func.create('box.info')
237+
box.schema.user.grant('guest', 'execute', 'function', 'box.info')
238+
on Tarantool instances.
229239
: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).
240+
for each connection in the pool. Be careful with reconnect
241+
parameters in ConnectionPool since every status refresh is
242+
also a request with reconnection. Default is 0 (fail after
243+
first attempt).
234244
: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.
245+
attempts for each connection in the pool. Be careful with
246+
reconnect parameters in ConnectionPool since every status
247+
refresh is also a request with reconnection. Default is 0.
238248
:param StrategyInterface strategy_class: Class for choosing
239-
instance based on request mode. By default, round-robin
240-
strategy is used.
249+
instance based on request mode. By default, round-robin
250+
strategy is used.
241251
:param int refresh_delay: Minimal time between RW/RO status
242-
refreshes.
252+
refreshes.
243253
'''
244254

245255
if not isinstance(addrs, list) or len(addrs) == 0:
246256
raise ConfigurationError("addrs must be non-empty list")
247257

248-
# Verify addresses.
258+
# Prepare addresses for usage.
249259
for addr in addrs:
250-
ok, msg = validate_address(addr)
260+
ok, msg = prepare_address(addr)
251261
if not ok:
252262
raise ConfigurationError(msg)
253263
self.addrs = addrs
@@ -272,7 +282,12 @@ def __init__(self,
272282
connect_now=False, # Connect in ConnectionPool.connect()
273283
encoding=encoding,
274284
call_16=call_16,
275-
connection_timeout=connection_timeout)
285+
connection_timeout=connection_timeout,
286+
transport=addr['transport'],
287+
ssl_key_file=addr['ssl_key_file'],
288+
ssl_cert_file=addr['ssl_cert_file'],
289+
ssl_ca_file=addr['ssl_ca_file'],
290+
ssl_ciphers=addr['ssl_ciphers'])
276291
)
277292

278293
if connect_now:
@@ -464,7 +479,7 @@ def ping(self, *, mode=None, **kwargs):
464479
def select(self, space_name, key, *, mode=Mode.ANY, **kwargs):
465480
'''
466481
:param tarantool.Mode mode: Request mode (default is
467-
ANY).
482+
ANY).
468483
'''
469484

470485
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)

Diff for: tarantool/error.py

+8
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121

2222
import os
2323
import socket
24+
try:
25+
import ssl
26+
is_ssl_supported = True
27+
except ImportError:
28+
is_ssl_supported = False
2429
import sys
2530
import warnings
2631

@@ -205,10 +210,13 @@ def __init__(self, orig_exception=None, *args):
205210
if isinstance(orig_exception, socket.timeout):
206211
self.message = "Socket timeout"
207212
super(NetworkError, self).__init__(0, self.message)
213+
elif is_ssl_supported and isinstance(orig_exception, ssl.SSLError):
214+
super(NetworkError, self).__init__(orig_exception, *args)
208215
elif isinstance(orig_exception, socket.error):
209216
self.message = os.strerror(orig_exception.errno)
210217
super(NetworkError, self).__init__(
211218
orig_exception.errno, self.message)
219+
212220
else:
213221
super(NetworkError, self).__init__(orig_exception, *args)
214222

0 commit comments

Comments
 (0)