diff --git a/CHANGELOG.md b/CHANGELOG.md index f44d465..d3b2d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +**New features:** +* Support SSL encrypted connection to Tarantool EE (closes [#22](https://github.com/igorcoding/asynctnt/issues/22)) + ## v2.0.0 **Breaking changes:** * `Connection.sql()` method is renamed to `Connection.execute()` diff --git a/asynctnt/__init__.py b/asynctnt/__init__.py index 10fafed..d17c9db 100644 --- a/asynctnt/__init__.py +++ b/asynctnt/__init__.py @@ -1,3 +1,4 @@ +from .const import Transport from .connection import Connection, connect from .iproto.protocol import ( Iterator, Response, TarantoolTuple, PushIterator, diff --git a/asynctnt/connection.py b/asynctnt/connection.py index b4b2cb0..8d97bf3 100644 --- a/asynctnt/connection.py +++ b/asynctnt/connection.py @@ -1,12 +1,14 @@ import asyncio import enum import functools +import ssl import os from typing import Optional, Union from .api import Api +from .const import Transport from .exceptions import TarantoolDatabaseError, \ - ErrorCode, TarantoolError + ErrorCode, TarantoolError, SSLError from .iproto import protocol from .log import logger from .stream import Stream @@ -27,8 +29,10 @@ class ConnectionState(enum.IntEnum): class Connection(Api): __slots__ = ( - '_host', '_port', '_username', '_password', - '_fetch_schema', '_auto_refetch_schema', '_initial_read_buffer_size', + '_host', '_port', '_transport', '_ssl_key_file', + '_ssl_cert_file', '_ssl_ca_file', '_ssl_ciphers', + '_username', '_password', '_fetch_schema', + '_auto_refetch_schema', '_initial_read_buffer_size', '_encoding', '_connect_timeout', '_reconnect_timeout', '_request_timeout', '_ping_timeout', '_loop', '_state', '_state_prev', '_connection_transport', '_protocol', @@ -40,6 +44,11 @@ class Connection(Api): def __init__(self, *, host: str = '127.0.0.1', port: Union[int, str] = 3301, + transport: Optional[Transport] = Transport.DEFAULT, + ssl_key_file: Optional[str] = None, + ssl_cert_file: Optional[str] = None, + ssl_ca_file: Optional[str] = None, + ssl_ciphers: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, fetch_schema: bool = True, @@ -78,6 +87,22 @@ def __init__(self, *, :param port: Tarantool port (pass ``/path/to/sockfile`` to connect ot unix socket) + :param transport: + This parameter can be used to configure traffic encryption. + Pass ``asynctnt.Transport.SSL`` value to enable SSL + encryption (by default there is no encryption) + :param ssl_key_file: + A path to a private SSL key file. + Optional, mandatory if server uses CA file + :param ssl_cert_file: + A path to an SSL certificate file. + Optional, mandatory if server uses CA file + :param ssl_ca_file: + A path to a trusted certificate authorities (CA) file. + Optional + :param ssl_ciphers: + A colon-separated (:) list of SSL cipher suites + the connection can use. Optional :param username: Username to use for auth (if ``None`` you are connected as a guest) @@ -116,6 +141,13 @@ def __init__(self, *, super().__init__() self._host = host self._port = port + + 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 + self._username = username self._password = password self._fetch_schema = False if fetch_schema is None else fetch_schema @@ -220,6 +252,54 @@ def protocol_factory(self, on_connection_lost=self.connection_lost, loop=self._loop) + def _create_ssl_context(self): + 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) + + return context + except SSLError as e: + raise + except Exception as e: + raise SSLError(e) + async def _connect(self, return_exceptions: bool = True): if self._loop is None: self._loop = get_running_loop() @@ -246,6 +326,10 @@ async def full_connect(): while True: connected_fut = _create_future(self._loop) + ssl_context = None + if self._transport == Transport.SSL: + ssl_context = self._create_ssl_context() + if self._host.startswith('unix/'): unix_path = self._port assert isinstance(unix_path, str), \ @@ -260,13 +344,14 @@ async def full_connect(): conn = self._loop.create_unix_connection( functools.partial(self.protocol_factory, connected_fut), - unix_path - ) + unix_path, + ssl=ssl_context) else: conn = self._loop.create_connection( functools.partial(self.protocol_factory, connected_fut), - self._host, self._port) + self._host, self._port, + ssl=ssl_context) tr, pr = await conn @@ -330,6 +415,8 @@ async def full_connect(): logger.debug("connect is cancelled") self._reconnect_task = None raise + except ssl.SSLError as e: + raise SSLError(e) except Exception as e: if self._reconnect_timeout > 0: await self._wait_reconnect(e) diff --git a/asynctnt/const.py b/asynctnt/const.py new file mode 100644 index 0000000..8943124 --- /dev/null +++ b/asynctnt/const.py @@ -0,0 +1,3 @@ +class Transport(): + DEFAULT = '' + SSL = 'ssl' diff --git a/asynctnt/exceptions.py b/asynctnt/exceptions.py index 00fa09a..ceead6a 100644 --- a/asynctnt/exceptions.py +++ b/asynctnt/exceptions.py @@ -42,6 +42,12 @@ class TarantoolNotConnectedError(TarantoolNetworkError): """ pass +class SSLError(TarantoolError): + """ + Raised when something is wrong with encrypted connection + """ + pass + class ErrorCode(enum.IntEnum): """ diff --git a/docs/examples.md b/docs/examples.md index 480861f..e1e51d7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -65,3 +65,32 @@ async def main(): asyncio.run(main()) ``` + +## Connect with SSL encryption +```python +import asyncio +import asynctnt + + +async def main(): + conn = asynctnt.Connection(host='127.0.0.1', + port=3301, + transport=asynctnt.Transport.SSL, + ssl_key_file='./ssl/host.key', + ssl_cert_file='./ssl/host.crt', + ssl_ca_file='./ssl/ca.crt', + ssl_ciphers='ECDHE-RSA-AES256-GCM-SHA384') + await conn.connect() + + resp = await conn.ping() + print(resp) + + await conn.disconnect() + +asyncio.run(main()) +``` + +Stdout: +``` + +```