Skip to content

Commit

Permalink
api: support of SSL protocol
Browse files Browse the repository at this point in the history
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
  • Loading branch information
oleg-jukovec authored and DifferentialOrange committed Jun 20, 2022
1 parent 46907c7 commit ffa3890
Show file tree
Hide file tree
Showing 17 changed files with 870 additions and 88 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

### Added
- SSL support (PR #220, #217).

### Changed

Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.PHONY: test
test:
python setup.py test
testdata:
cd ./test/data/; ./generate.sh
coverage:
python -m coverage run -p --source=. setup.py test
cov-html:
Expand Down
116 changes: 111 additions & 5 deletions tarantool/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
import time
import errno
import socket
try:
import ssl
is_ssl_supported = True
except ImportError:
is_ssl_supported = False
import sys
import abc

import ctypes
Expand All @@ -19,7 +25,6 @@

import msgpack

import tarantool.error
from tarantool.response import Response
from tarantool.request import (
Request,
Expand All @@ -44,6 +49,12 @@
SOCKET_TIMEOUT,
RECONNECT_MAX_ATTEMPTS,
RECONNECT_DELAY,
DEFAULT_TRANSPORT,
SSL_TRANSPORT,
DEFAULT_SSL_KEY_FILE,
DEFAULT_SSL_CERT_FILE,
DEFAULT_SSL_CA_FILE,
DEFAULT_SSL_CIPHERS,
REQUEST_TYPE_OK,
REQUEST_TYPE_ERROR,
IPROTO_GREETING_SIZE,
Expand All @@ -53,6 +64,7 @@
from tarantool.error import (
Error,
NetworkError,
SslError,
DatabaseError,
InterfaceError,
ConfigurationError,
Expand Down Expand Up @@ -196,15 +208,28 @@ def __init__(self, host, port,
encoding=ENCODING_DEFAULT,
use_list=True,
call_16=False,
connection_timeout=CONNECTION_TIMEOUT):
connection_timeout=CONNECTION_TIMEOUT,
transport=DEFAULT_TRANSPORT,
ssl_key_file=DEFAULT_SSL_KEY_FILE,
ssl_cert_file=DEFAULT_SSL_CERT_FILE,
ssl_ca_file=DEFAULT_SSL_CA_FILE,
ssl_ciphers=DEFAULT_SSL_CIPHERS):
'''
Initialize a connection to the server.
:param str host: Server hostname or IP-address
:param int port: Server port
:param bool connect_now: if True (default) than __init__() actually
creates network connection.
if False than you have to call connect() manualy.
creates network connection. if False than you have to call
connect() manualy.
:param str transport: It enables SSL encryption for a connection if set
to ssl. At least Python 3.5 is required for SSL encryption.
:param str ssl_key_file: A path to a private SSL key file.
:param str ssl_cert_file: A path to an SSL certificate file.
:param str ssl_ca_file: A path to a trusted certificate authorities
(CA) file.
:param str ssl_ciphers: A colon-separated (:) list of SSL cipher suites
the connection can use.
'''

if msgpack.version >= (1, 0, 0) and encoding not in (None, 'utf-8'):
Expand Down Expand Up @@ -237,6 +262,11 @@ def __init__(self, host, port,
self.use_list = use_list
self.call_16 = call_16
self.connection_timeout = connection_timeout
self.transport = transport
self.ssl_key_file = ssl_key_file
self.ssl_cert_file = ssl_cert_file
self.ssl_ca_file = ssl_ca_file
self.ssl_ciphers = ssl_ciphers
if connect_now:
self.connect()

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

def connect_basic(self):
if self.host == None:
if self.host is None:
self.connect_unix()
else:
self.connect_tcp()

def connect_tcp(self):
'''
Create connection to the host and port specified in __init__().
:raise: `NetworkError`
'''

Expand All @@ -282,6 +313,7 @@ def connect_tcp(self):
def connect_unix(self):
'''
Create connection to the host and port specified in __init__().
:raise: `NetworkError`
'''

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

def wrap_socket_ssl(self):
'''
Wrap an existing socket with SSL socket.
:raise: SslError
:raise: `ssl.SSLError`
'''
if not is_ssl_supported:
raise SslError("SSL is unsupported by the python.")

ver = sys.version_info
if ver[0] < 3 or (ver[0] == 3 and ver[1] < 5):
raise SslError("SSL transport is supported only since " +
"python 3.5")

if ((self.ssl_cert_file is None and self.ssl_key_file is not None)
or (self.ssl_cert_file is not None and self.ssl_key_file is None)):
raise SslError("ssl_cert_file and ssl_key_file should be both " +
"configured or not")

try:
if hasattr(ssl, 'TLSVersion'):
# Since python 3.7
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
# Reset to default OpenSSL values.
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# Require TLSv1.2, because other protocol versions don't seem
# to support the GOST cipher.
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.maximum_version = ssl.TLSVersion.TLSv1_2
else:
# Deprecated, but it works for python < 3.7
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)

if self.ssl_cert_file:
# If the password argument is not specified and a password is
# required, OpenSSL’s built-in password prompting mechanism
# will be used to interactively prompt the user for a password.
#
# We should disable this behaviour, because a python
# application that uses the connector unlikely assumes
# interaction with a human + a Tarantool implementation does
# not support this at least for now.
def password_raise_error():
raise SslError("a password for decrypting the private " +
"key is unsupported")
context.load_cert_chain(certfile=self.ssl_cert_file,
keyfile=self.ssl_key_file,
password=password_raise_error)

if self.ssl_ca_file:
context.load_verify_locations(cafile=self.ssl_ca_file)
context.verify_mode = ssl.CERT_REQUIRED
# A Tarantool implementation does not check hostname. We don't
# do that too. As a result we don't set here:
# context.check_hostname = True

if self.ssl_ciphers:
context.set_ciphers(self.ssl_ciphers)

self._socket = context.wrap_socket(self._socket)
except SslError as e:
raise e
except Exception as e:
raise SslError(e)

def handshake(self):
greeting_buf = self._recv(IPROTO_GREETING_SIZE)
greeting = greeting_decode(greeting_buf)
Expand All @@ -316,11 +415,16 @@ def connect(self):
since it is called when you create an `Connection` instance.
:raise: `NetworkError`
:raise: `SslError`
'''
try:
self.connect_basic()
if self.transport == SSL_TRANSPORT:
self.wrap_socket_ssl()
self.handshake()
self.load_schema()
except SslError as e:
raise e
except Exception as e:
self.connected = False
raise NetworkError(e)
Expand Down Expand Up @@ -447,6 +551,8 @@ def check(): # Check that connection is alive
raise NetworkError(
socket.error(last_errno, errno.errorcode[last_errno]))
attempt += 1
if self.transport == SSL_TRANSPORT:
self.wrap_socket_ssl()
self.handshake()

def _send_request(self, request):
Expand Down
76 changes: 48 additions & 28 deletions tarantool/connection_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,11 @@
PoolTolopogyError,
PoolTolopogyWarning,
ConfigurationError,
DatabaseError,
NetworkError,
NetworkWarning,
tnt_strerror,
warn
)
from tarantool.utils import ENCODING_DEFAULT
from tarantool.mesh_connection import validate_address
from tarantool.mesh_connection import prepare_address


class Mode(Enum):
Expand Down Expand Up @@ -190,13 +187,15 @@ class ConnectionPool(ConnectionInterface):
ConnectionPool API is the same as a plain Connection API.
On each request, a connection is chosen to execute this request.
Connection is selected based on request mode:
* Mode.ANY chooses any instance.
* Mode.RW chooses an RW instance.
* Mode.RO chooses an RO instance.
* Mode.PREFER_RW chooses an RW instance, if possible, RO instance
otherwise.
* Mode.PREFER_RO chooses an RO instance, if possible, RW instance
otherwise.
All requests that are guaranteed to write (insert, replace, delete,
upsert, update) use RW mode by default. select uses ANY by default. You
can set the mode explicitly. call, eval, execute and ping requests
Expand All @@ -218,39 +217,55 @@ def __init__(self,
'''
Initialize connections to the cluster of servers.
:param list addrs: List of {host: , port:} dictionaries,
describing server addresses.
:user str Username used to authenticate. User must be able
to call box.info function. For example, to give grants to
'guest' user, evaluate
box.schema.func.create('box.info')
box.schema.user.grant('guest', 'execute', 'function', 'box.info')
on Tarantool instances.
:param list addrs: List of
.. code-block:: python
{
host: "str", # optional
port: int or "str", # mandatory
transport: "str", # optional
ssl_key_file: "str", # optional
ssl_cert_file: "str", # optional
ssl_ca_file: "str", # optional
ssl_ciphers: "str" # optional
}
dictionaries, describing server addresses.
See :func:`tarantool.Connection` parameters with same names.
:param str user: Username used to authenticate. User must be able
to call box.info function. For example, to give grants to
'guest' user, evaluate
box.schema.func.create('box.info')
box.schema.user.grant('guest', 'execute', 'function', 'box.info')
on Tarantool instances.
:param int reconnect_max_attempts: Max attempts to reconnect
for each connection in the pool. Be careful with reconnect
parameters in ConnectionPool since every status refresh is
also a request with reconnection. Default is 0 (fail after
first attempt).
for each connection in the pool. Be careful with reconnect
parameters in ConnectionPool since every status refresh is
also a request with reconnection. Default is 0 (fail after
first attempt).
:param float reconnect_delay: Time between reconnect
attempts for each connection in the pool. Be careful with
reconnect parameters in ConnectionPool since every status
refresh is also a request with reconnection. Default is 0.
attempts for each connection in the pool. Be careful with
reconnect parameters in ConnectionPool since every status
refresh is also a request with reconnection. Default is 0.
:param StrategyInterface strategy_class: Class for choosing
instance based on request mode. By default, round-robin
strategy is used.
instance based on request mode. By default, round-robin
strategy is used.
:param int refresh_delay: Minimal time between RW/RO status
refreshes.
refreshes.
'''

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

# Verify addresses.
# Prepare addresses for usage.
new_addrs = []
for addr in addrs:
ok, msg = validate_address(addr)
if not ok:
new_addr, msg = prepare_address(addr)
if not new_addr:
raise ConfigurationError(msg)
self.addrs = addrs
new_addrs.append(new_addr)
self.addrs = new_addrs

# Create connections
self.pool = {}
Expand All @@ -272,7 +287,12 @@ def __init__(self,
connect_now=False, # Connect in ConnectionPool.connect()
encoding=encoding,
call_16=call_16,
connection_timeout=connection_timeout)
connection_timeout=connection_timeout,
transport=addr['transport'],
ssl_key_file=addr['ssl_key_file'],
ssl_cert_file=addr['ssl_cert_file'],
ssl_ca_file=addr['ssl_ca_file'],
ssl_ciphers=addr['ssl_ciphers'])
)

if connect_now:
Expand Down Expand Up @@ -464,7 +484,7 @@ def ping(self, *, mode=None, **kwargs):
def select(self, space_name, key, *, mode=Mode.ANY, **kwargs):
'''
:param tarantool.Mode mode: Request mode (default is
ANY).
ANY).
'''

return self._send(mode, 'select', space_name, key, **kwargs)
Expand Down
12 changes: 12 additions & 0 deletions tarantool/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@
RECONNECT_MAX_ATTEMPTS = 10
# Default delay between attempts to reconnect (seconds)
RECONNECT_DELAY = 0.1
# Default value for transport
DEFAULT_TRANSPORT = ""
# Value for SSL transport
SSL_TRANSPORT = "ssl"
# Default value for a path to SSL key file
DEFAULT_SSL_KEY_FILE = None
# Default value for a path to SSL certificate file
DEFAULT_SSL_CERT_FILE = None
# Default value for a path to SSL certificatea uthorities file
DEFAULT_SSL_CA_FILE = None
# Default value for list of SSL ciphers
DEFAULT_SSL_CIPHERS = None
# Default cluster nodes list refresh interval (seconds)
CLUSTER_DISCOVERY_DELAY = 60
# Default cluster nodes state refresh interval (seconds)
Expand Down
Loading

0 comments on commit ffa3890

Please sign in to comment.