diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5d77881f..5b347773 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,16 +3,19 @@ name: testing on: push: pull_request: + pull_request_target: + types: [labeled] jobs: - run_tests_linux: + run_tests_ce_linux: # We want to run on external PRs, but not on our own internal # PRs as they'll be run by the push to the branch. # # The main trick is described here: # https://github.com/Dart-Code/Dart-Code/pull/2375 - if: github.event_name == 'push' || - github.event.pull_request.head.repo.full_name != github.repository + if: (github.event_name == 'push') || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) runs-on: ubuntu-20.04 @@ -97,14 +100,93 @@ jobs: - name: Run tests run: make test - run_tests_windows: + run_tests_ee_linux: + # The same as for run_tests_ce_linux, but it does not run on pull requests + # from forks by default. Tests will run only when the pull request is + # labeled with `full-ci`. To avoid security problems, the label must be + # reset manually for every run. + # + # We need to use `pull_request_target` because it has access to base + # repository secrets unlike `pull_request`. + if: (github.event_name == 'push') || + (github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name != github.repository && + github.event.label.name == 'full-ci') + + runs-on: ubuntu-20.04 + + strategy: + fail-fast: false + matrix: + tarantool: + - '1.10.11-0-gf0b0e7ecf-r470' + - '2.8.3-21-g7d35cd2be-r470' + - '2.10.0-1-gfa775b383-r486-linux-x86_64' + python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10'] + msgpack-deps: + # latest msgpack will be installed as a part of requirements.txt + - '' + + steps: + - name: Clone the connector + uses: actions/checkout@v2 + # This is needed for pull_request_target because this event runs in the + # context of the base commit of the pull request. It works fine for + # `push` and `workflow_dispatch` because the default behavior is used + # if `ref` and `repository` are empty. + with: + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + + - name: Install tarantool ${{ matrix.tarantool }} + run: | + ARCHIVE_NAME=tarantool-enterprise-bundle-${{ matrix.tarantool }}.tar.gz + curl -O -L https://${{ secrets.SDK_DOWNLOAD_TOKEN }}@download.tarantool.io/enterprise/${ARCHIVE_NAME} + tar -xzf ${ARCHIVE_NAME} + rm -f ${ARCHIVE_NAME} + + - name: Setup Python for tests + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + + - name: Install specific version of msgpack package + if: startsWith(matrix.msgpack-deps, 'msgpack==') == true + run: | + pip install ${{ matrix.msgpack-deps }} + + - name: Install specific version of msgpack-python package + # msgpack package is a replacement for deprecated msgpack-python. + # To test compatibility with msgpack-python we must ignore + # requirements.txt install of msgpack package by overwriting it + # with sed. + if: startsWith(matrix.msgpack-deps, 'msgpack-python==') == true + run: | + pip install ${{ matrix.msgpack-deps }} + sed -i -e "s/^msgpack.*$/${{ matrix.msgpack-deps }}/" requirements.txt + + - name: Install package requirements + run: pip install -r requirements.txt + + - name: Install test requirements + run: pip install -r requirements-test.txt + + - name: Run tests + run: | + source tarantool-enterprise/env.sh + make test + env: + TEST_TNT_SSL: ${{ matrix.tarantool == '2.10.0-1-gfa775b383-r486-linux-x86_64' }} + + run_tests_ce_windows: # We want to run on external PRs, but not on our own internal # PRs as they'll be run by the push to the branch. # # The main trick is described here: # https://github.com/Dart-Code/Dart-Code/pull/2375 if: github.event_name == 'push' || - github.event.pull_request.head.repo.full_name != github.repository + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository) runs-on: windows-2022 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd4678b..12dc9c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- SSL support (PR #220, #217). +- Tarantool Enterprise testing workflow on GitHub actions (PR #220). ### Changed diff --git a/Makefile b/Makefile index c9dd894c..bfa3516b 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/tarantool/connection.py b/tarantool/connection.py index 73574e46..b6c20a4f 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -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 @@ -19,7 +25,6 @@ import msgpack -import tarantool.error from tarantool.response import Response from tarantool.request import ( Request, @@ -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, @@ -53,6 +64,7 @@ from tarantool.error import ( Error, NetworkError, + SslError, DatabaseError, InterfaceError, ConfigurationError, @@ -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'): @@ -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() @@ -255,7 +285,7 @@ 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() @@ -263,6 +293,7 @@ def connect_basic(self): def connect_tcp(self): ''' Create connection to the host and port specified in __init__(). + :raise: `NetworkError` ''' @@ -282,6 +313,7 @@ def connect_tcp(self): def connect_unix(self): ''' Create connection to the host and port specified in __init__(). + :raise: `NetworkError` ''' @@ -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) @@ -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) @@ -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): diff --git a/tarantool/connection_pool.py b/tarantool/connection_pool.py index b0516d85..73e2c3c8 100644 --- a/tarantool/connection_pool.py +++ b/tarantool/connection_pool.py @@ -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): @@ -190,6 +187,7 @@ 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. @@ -197,6 +195,7 @@ class ConnectionPool(ConnectionInterface): 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 @@ -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 = {} @@ -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: @@ -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) diff --git a/tarantool/const.py b/tarantool/const.py index 9eb834a8..476af38a 100644 --- a/tarantool/const.py +++ b/tarantool/const.py @@ -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) diff --git a/tarantool/error.py b/tarantool/error.py index c7165a9b..ba71ac8a 100644 --- a/tarantool/error.py +++ b/tarantool/error.py @@ -21,6 +21,11 @@ import os import socket +try: + import ssl + is_ssl_supported = True +except ImportError: + is_ssl_supported = False import sys import warnings @@ -217,6 +222,19 @@ class NetworkWarning(UserWarning): '''Warning related to network''' pass +class SslError(DatabaseError): + '''Error related to SSL''' + + def __init__(self, orig_exception=None, *args): + self.errno = 0 + if hasattr(orig_exception, 'errno'): + self.errno = orig_exception.errno + if orig_exception: + if is_ssl_supported and isinstance(orig_exception, ssl.SSLError): + super(SslError, self).__init__(orig_exception, *args) + else: + super(SslError, self).__init__(orig_exception, *args) + class ClusterDiscoveryWarning(UserWarning): '''Warning related to cluster discovery''' diff --git a/tarantool/mesh_connection.py b/tarantool/mesh_connection.py index 7a1d37b2..97631b77 100644 --- a/tarantool/mesh_connection.py +++ b/tarantool/mesh_connection.py @@ -21,6 +21,11 @@ SOCKET_TIMEOUT, RECONNECT_MAX_ATTEMPTS, RECONNECT_DELAY, + DEFAULT_TRANSPORT, + DEFAULT_SSL_KEY_FILE, + DEFAULT_SSL_CERT_FILE, + DEFAULT_SSL_CA_FILE, + DEFAULT_SSL_CIPHERS, CLUSTER_DISCOVERY_DELAY, ) @@ -33,6 +38,14 @@ except NameError: string_types = str +default_addr_opts = { + '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 +} + def parse_uri(uri): # TODO: Support Unix sockets. @@ -47,19 +60,39 @@ def parse_error(uri, msg): if uri.count(':') != 1: return parse_error(uri, 'does not match host:port scheme') - host, port_str = uri.split(':', 1) + host, opts_str = uri.split(':', 1) if not host: return parse_error(uri, 'host value is empty') + opts_array = opts_str.split('?', 1) + port_str = opts_array[0] + if len(opts_array) > 1: + opts_str = opts_array[1] + else: + opts_str = "" + + result = {'host': host} try: - port = int(port_str) + result['port'] = int(port_str) except ValueError: return parse_error(uri, 'port should be a number') - return {'host': host, 'port': port}, None + for k, v in default_addr_opts.items(): + result[k] = v + + if opts_str != "": + for opt_str in opts_str.split('&'): + opt = opt_str.split('=') + if len(opt) != 2: + continue + for k in default_addr_opts: + if k == opt[0]: + result[k] = opt[1] + + return result, None -def validate_address(address): +def prepare_address(address): def format_error(address, err): return None, 'Address %s: %s' % (str(address), err) @@ -69,44 +102,62 @@ def format_error(address, err): if 'port' not in address or address['port'] is None: return format_error(address, 'port is not set or None') - if isinstance(address['port'], int): + result = {} + for k, v in address.items(): + result[k] = v + # Set default values. + for k, v in default_addr_opts.items(): + if k not in result: + result[k] = v + + if isinstance(result['port'], int): # Looks like an inet address. # Validate host. - if 'host' not in address or address['host'] is None: - return format_error(address, - 'host is mandatory for an inet address') - if not isinstance(address['host'], string_types): - return format_error(address, - 'host must be a string for an inet address') + if 'host' not in result or result['host'] is None: + return format_error(result, + 'host is mandatory for an inet result') + if not isinstance(result['host'], string_types): + return format_error(result, + 'host must be a string for an inet result') # Validate port. - if not isinstance(address['port'], int): - return format_error(address, - 'port must be an int for an inet address') - if address['port'] < 1 or address['port'] > 65535: - return format_error(address, 'port must be in range [1, 65535] ' - 'for an inet address') + if not isinstance(result['port'], int): + return format_error(result, + 'port must be an int for an inet result') + if result['port'] < 1 or result['port'] > 65535: + return format_error(result, 'port must be in range [1, 65535] ' + 'for an inet result') # Looks okay. - return True, None - elif isinstance(address['port'], string_types): + return result, None + elif isinstance(result['port'], string_types): # Looks like a unix address. # Expect no host. - if 'host' in address and address['host'] is not None: + if 'host' in result and result['host'] is not None: return format_error( - address, 'host must be unset or None for a unix address') + result, 'host must be unset or None for a unix result') # Validate port. - if not isinstance(address['port'], string_types): - return format_error(address, - 'port must be a string for a unix address') + if not isinstance(result['port'], string_types): + return format_error(result, + 'port must be a string for a unix result') # Looks okay. - return True, None + return result, None + + return format_error(result, 'port must be an int or a string') + - return format_error(address, 'port must be an int or a string') +def update_connection(conn, address): + conn.host = address["host"] + conn.port = address["port"] + conn.transport = address['transport'] + conn.ssl_key_file = address['ssl_key_file'] + conn.ssl_cert_file = address['ssl_cert_file'] + conn.ssl_ca_file = address['ssl_ca_file'] + conn.ssl_ciphers = address['ssl_ciphers'] class RoundRobinStrategy(object): @@ -168,7 +219,7 @@ class MeshConnection(Connection): function get_cluster_nodes() return { '192.168.0.1:3301', - '192.168.0.2:3302', + '192.168.0.2:3302?transport=ssl&ssl_ca_file=/path/to/ca.cert', -- ... } end @@ -212,6 +263,11 @@ def __init__(self, host=None, port=None, encoding=ENCODING_DEFAULT, call_16=False, 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, addrs=None, strategy_class=RoundRobinStrategy, cluster_discovery_function=None, @@ -223,33 +279,39 @@ def __init__(self, host=None, port=None, addrs = addrs[:] if host and port: - addrs.insert(0, {'host': host, 'port': port}) + addrs.insert(0, {'host': host, + 'port': port, + 'transport': transport, + 'ssl_key_file': ssl_key_file, + 'ssl_cert_file': ssl_cert_file, + 'ssl_ca_file': ssl_ca_file, + 'ssl_ciphers': ssl_ciphers}) # Verify that at least one address is provided. if not addrs: raise ConfigurationError( 'Neither "host" and "port", nor "addrs" arguments are set') - # 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) + new_addrs.append(new_addr) + addrs = new_addrs self.strategy_class = strategy_class self.strategy = strategy_class(addrs) - addr = self.strategy.getnext() - host = addr['host'] - port = addr['port'] self.cluster_discovery_function = cluster_discovery_function self.cluster_discovery_delay = cluster_discovery_delay self.last_nodes_refresh = 0 super(MeshConnection, self).__init__( - host=host, - port=port, + host=addr['host'], + port=addr['port'], user=user, password=password, socket_timeout=socket_timeout, @@ -258,7 +320,12 @@ def __init__(self, host=None, port=None, connect_now=connect_now, 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']) def connect(self): super(MeshConnection, self).connect() @@ -280,8 +347,7 @@ def _opt_reconnect(self): except NetworkError as e: last_error = e addr = self.strategy.getnext() - self.host = addr["host"] - self.port = addr["port"] + update_connection(self, addr) if last_error: raise last_error @@ -315,7 +381,7 @@ def _opt_refresh_instances(self): warn(msg, ClusterDiscoveryWarning) return - # Validate received address list. + # Prepare for usage received address list. new_addrs = [] for uri in resp.data[0]: addr, msg = parse_uri(uri) @@ -323,12 +389,12 @@ def _opt_refresh_instances(self): warn(msg, ClusterDiscoveryWarning) continue - ok, msg = validate_address(addr) - if not ok: + new_addr, msg = prepare_address(addr) + if not new_addr: warn(msg, ClusterDiscoveryWarning) continue - new_addrs.append(addr) + new_addrs.append(new_addr) if not new_addrs: msg = "got no correct URIs, skipped addresses updating" @@ -340,12 +406,17 @@ def _opt_refresh_instances(self): # Disconnect from a current instance if it was gone from # an instance list and connect to one of new instances. - current_addr = {'host': self.host, 'port': self.port} + current_addr = {'host': self.host, + 'port': self.port, + 'transport': self.transport, + 'ssl_key_file': self.ssl_key_file, + 'ssl_cert_file': self.ssl_cert_file, + 'ssl_ca_file': self.ssl_ca_file, + 'ssl_ciphers': self.ssl_ciphers} if current_addr not in self.strategy.addrs: self.close() addr = self.strategy.getnext() - self.host = addr['host'] - self.port = addr['port'] + update_connection(self, addr) self._opt_reconnect() def _send_request(self, request): diff --git a/test/data/ca.crt b/test/data/ca.crt new file mode 100644 index 00000000..013f5483 --- /dev/null +++ b/test/data/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLzCCAhegAwIBAgIUMwa7m6dtjVYPK5iZAMX8YUuHtxEwDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMCcxCzAJBgNVBAYTAlVTMRgwFgYD +VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC923p9pD1ajiAPsM2W6cnjSkexHX2+sJeaLXL6zdFeUjLYRAnfzJ9xVih7 +91yWbuJ9OAswWmz83JrtSm1GqZpFucSz5pFqW2AVrhX5TezlxyH9QwPl+Scu1kCd ++wu7Fgkuw7a0SOpYafPQ6smucCWbxkyZTNgysNuWswykal4VCWyekaY/OojEImoG +smGOXe1Pr2x8XsiWVau1UJ0jj/vh5VzF05mletaUOoQ+iorIHAfnOm2K53jAZlNG +X83VJ1ijSDwiKcnFKcQqlq2Zt88UpxMMv0UyFbDCrOj5qfBbAvzZj5IgUi/NvoZz +M+lzwT+/0mADkAHB6EVa4R29zM+fAgMBAAGjUzBRMB0GA1UdDgQWBBSloRx6dBUI +gJb0yzP2c5zQdQQ+2TAfBgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCCUEnzpu8hZAckICLR +5JRDUiHJ3yJ5iv0b9ChNaz/AQBQGRE8bOPC2M/ZG1RuuQ8IbRbzK0fy1ty9KpG2D +JC9iDL6zPOC3e5x2H8Gxbhvjz4QnHPbYTfdJSmX5tJyNIrJ77g4SW5g8eFApTHyY +5KwRD3IDEu4pZNGsM7l0ODBC/4lvR8u7wPJDGyJBpE3uAKC20XqbG8BWm3kPb9+T +wE4Ak/FEXcwARB0fJ6Jni9iK3TeReyB3rpsYJa4N9iY6f1qNy4qQZ8Va6EWPSNnB +FhvCIYt4LdgM9ffUuHPrCX7qdgSNiL4VijgLaEHjFUUlLb6NHgQfYx/JG7wstiKs +Syzb +-----END CERTIFICATE----- diff --git a/test/data/empty b/test/data/empty new file mode 100644 index 00000000..e69de29b diff --git a/test/data/generate.sh b/test/data/generate.sh new file mode 100755 index 00000000..437ecf7f --- /dev/null +++ b/test/data/generate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -xeuo pipefail +# An example how-to re-generate testing certificates (because usually +# TLS certificates have expiration dates and some day they will expire). +# +# The instruction is valid for: +# +# $ openssl version +# OpenSSL 3.0.2 15 Mar 2022 (Library: OpenSSL 3.0.2 15 Mar 2022) + +cat < domains_localhost.ext +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +EOF + +cat < domains_invalidhost.ext +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names +[alt_names] +DNS.1 = invalidhostname +EOF + +openssl req -x509 -nodes -new -sha256 -days 8192 -newkey rsa:2048 -keyout ca.key -out ca.pem -subj "/C=US/CN=Example-Root-CA" +openssl x509 -outform pem -in ca.pem -out ca.crt + +openssl req -new -nodes -newkey rsa:2048 -keyout localhost.key -out localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost" +openssl x509 -req -sha256 -days 8192 -in localhost.csr -CA ca.pem -CAkey ca.key -CAcreateserial -extfile domains_localhost.ext -out localhost.crt +openssl x509 -req -sha256 -days 8192 -in localhost.csr -CA ca.pem -CAkey ca.key -CAcreateserial -extfile domains_invalidhost.ext -out invalidhost.crt diff --git a/test/data/invalidhost.crt b/test/data/invalidhost.crt new file mode 100644 index 00000000..de28671d --- /dev/null +++ b/test/data/invalidhost.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkjCCAnqgAwIBAgIUV7NbprG6FEvrSP0kZ7pT9s7eN7swDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMGcxCzAJBgNVBAYTAlVTMRIwEAYD +VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt +cGxlLUNlcnRpZmljYXRlczESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqGqKNkOVMGeIClmjLRf02UhtpYcGYVmiblpB +rqbI7eXKKIXMm4ppEEC/1YMVx/iYNYUK0xXxtzZUe1R6L5PYKAm1X+EQ4Sipyj/s +J+qsHxC65mavKB0ylZLZxAjZbiqBBYWwt0uz6ihHAtNXmoBzCE/mTRI3vTOd+CGQ +EI5pLGB85UuyvTfMKFwV9cTfltqGNyAZ670TFxtIwLeGuExfAFTVyofFWb8Kniby +EwKm/1giFl0HrKsHzPljKjlug6lcUeGxooTUJ9sxe6zPYGy2c6EqyV62/UVzgxv9 +LNejeh3vlFmQbeawrwvQSMNi+sVuiaYmq/FIw5e4pUYUTjf+SQIDAQABo3YwdDAf +BgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAJBgNVHRMEAjAAMAsGA1Ud +DwQEAwIE8DAaBgNVHREEEzARgg9pbnZhbGlkaG9zdG5hbWUwHQYDVR0OBBYEFNpJ +/WkoMwKCdo0w0HV8aYm1m7ayMA0GCSqGSIb3DQEBCwUAA4IBAQC2tCfqPF2QrieZ +5632SyuX9oDzBCPQv2vi68QRtL+VxjmJ+IPLHdpZ96jTM7pYIAQ5QVm357JXLixU +NJ0eqgGIFrY4Evx91AGEAX20Ccn8CCXK3LsG1z1UWrvH/txEyOecuLCukaDI5ejq +z1/CKJhxF7bBfukfG2X8qWqqUNRQpkdQObMwZ6Np/GhITIDldxRMIaP05pUGPybR +CrEiC5F5lwgVAwlNhnfJuBcH3XMKWFZuiyur3O6PfSmUByainSnLY94RefofyEct +t20ikQssE6XcX/soTtmwOvIGHHMGcuKBbTwlF0dxv9pLrikpXrv0sf3mT+abUqND +oPmVcDJp +-----END CERTIFICATE----- diff --git a/test/data/localhost.crt b/test/data/localhost.crt new file mode 100644 index 00000000..765b843e --- /dev/null +++ b/test/data/localhost.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkjCCAnqgAwIBAgIUI7y4bpqOVjvp9aEzUlsSO4pZgjAwDQYJKoZIhvcNAQEL +BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y +MjA2MTYwODQzMThaFw00NDExMTkwODQzMThaMGcxCzAJBgNVBAYTAlVTMRIwEAYD +VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt +cGxlLUNlcnRpZmljYXRlczESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqGqKNkOVMGeIClmjLRf02UhtpYcGYVmiblpB +rqbI7eXKKIXMm4ppEEC/1YMVx/iYNYUK0xXxtzZUe1R6L5PYKAm1X+EQ4Sipyj/s +J+qsHxC65mavKB0ylZLZxAjZbiqBBYWwt0uz6ihHAtNXmoBzCE/mTRI3vTOd+CGQ +EI5pLGB85UuyvTfMKFwV9cTfltqGNyAZ670TFxtIwLeGuExfAFTVyofFWb8Kniby +EwKm/1giFl0HrKsHzPljKjlug6lcUeGxooTUJ9sxe6zPYGy2c6EqyV62/UVzgxv9 +LNejeh3vlFmQbeawrwvQSMNi+sVuiaYmq/FIw5e4pUYUTjf+SQIDAQABo3YwdDAf +BgNVHSMEGDAWgBSloRx6dBUIgJb0yzP2c5zQdQQ+2TAJBgNVHRMEAjAAMAsGA1Ud +DwQEAwIE8DAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwHQYDVR0OBBYEFNpJ +/WkoMwKCdo0w0HV8aYm1m7ayMA0GCSqGSIb3DQEBCwUAA4IBAQC2UFwSoqAMfg1h +xhYauemq13+JXPOnfoR74WzJc8Wva51Bqr8YpVxXU8GCViZKdWi/6sT5h//M4Zrp +wmcUruAQinRUy7RzKoXFHL7g6hQOE440gqaePE/PvjTde8l7FeiGTCSfAqIIFpsz +8YhVajenrzt9ppaHnad/N59uCnIULZrezRq8wJl8Zw82IR/Szcu/4O/tSimYuleY +pNX1h5w2mfpNmKeXkseU8cid1GhCnBg2FK6t6xZ4sSCL2nlpNKsbYvLg5rViRavO +7roUcU4BKK5NnGuYOPKYycSpC500V+shnCq4vTZSsPTOT2dHdMMK5HguxzHxixQv +yPeWBYqy +-----END CERTIFICATE----- diff --git a/test/data/localhost.key b/test/data/localhost.key new file mode 100644 index 00000000..5fbcfbaa --- /dev/null +++ b/test/data/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCoaoo2Q5UwZ4gK +WaMtF/TZSG2lhwZhWaJuWkGupsjt5coohcybimkQQL/VgxXH+Jg1hQrTFfG3NlR7 +VHovk9goCbVf4RDhKKnKP+wn6qwfELrmZq8oHTKVktnECNluKoEFhbC3S7PqKEcC +01eagHMIT+ZNEje9M534IZAQjmksYHzlS7K9N8woXBX1xN+W2oY3IBnrvRMXG0jA +t4a4TF8AVNXKh8VZvwqeJvITAqb/WCIWXQesqwfM+WMqOW6DqVxR4bGihNQn2zF7 +rM9gbLZzoSrJXrb9RXODG/0s16N6He+UWZBt5rCvC9BIw2L6xW6Jpiar8UjDl7il +RhRON/5JAgMBAAECggEAHWxlorbadvcziYlhDIUJsjdo7pkhOHtSOUDcBlEdvBBg +KgW8OjVrhxsk2L7a3JG2N+17N2c3UGi1yEk5QpwsEMynay2VRx0VUuApmEyzzwab +fJrWgaXeO0sJcCoSoKBc47PYbKGVeHSaeWgmfzfvQPXCmNb0tYGx2NK2Smoy/j1B +lXgODPkXHuzj0LOA3OkapgrxqHpN+kPjAfaY8vKYBQ8lbROT3kjgjqEzykC3bCzj +ZNZArGovBRAGr7dvjdh791g3hN2cAgIWhTg4zu8N6gf18G1l4bH8nmRzWT/z7eJi +QvmGjXVPUEpBcWRZuHms5cGcxb7V6smvuJp4v1n+rQKBgQDa1rqNwVlk1Jo0oT5U +KUyJwjaVXa3Foy5oR/T66UDIEBiMEonfI/miMlwXRXdhC1WQTeddk5vX+pn3ISZT +mN6zwz2NGE1i4GmOLIG9a9JkCSPffqDiwYFd2uhbTfKNehIHOC4Xdg/UGz+vOGFZ +MxYiSrytYK6svgHjHlFPp/uP9QKBgQDFA9wVmE76FqVC7crA7Djkyt4cRU5LEILO +qp4AxWE8HU/vlht4PhVA/dgMTNkVLiyrSgTGm15FQKZWe2FMVaAnRcmLy6bRpcAM +fP4HNtwjRWHx1Q4lMRZLrZPO0W8RXUqgMgGd3w1kyJK/C9wnD/01h+3lAnJ1cHlD +5jub6RDkhQKBgQCUciSKFCY3p6ATI23MWVd5+yxblfhSoKbSRj2AFsnC7Gg6XDj6 +DMVBqTee8ZhRVAbupGnVqFOG5o+ae/orqv8mocIW++1CrUftEXPQsls9UJXs/VDV +gL3olJ4ZkX5/SdcA3rMlZwjFsNY6XdxrTaQuDtR+J59Vvm45Sk+N4T1cIQKBgG9d +zSzP2eT4pBZ/QJtpbIe4PXGRo74+6RJV09bvvBU1JJh0K7b+sRj55QSe9B9K6Kky +wBxcex9+eghs2gVCabOJeXJyfiwIG9VzWk1Nr4aok8MWAlb3tni099ZzAOu55pND +cTKCgZm0327rD1ltal62Jb3MclL8by/4lz18s7XZAoGBANSv/AdjlJUQ++9I+P1+ +g7rgrfWKLyQ8FSljO7dAOWsDjrFHSi2f2HCh3URcKqzdjG+/iK+MyKUlaUZDLCzf +QNgI+7n5I/aHfhRWo7ytRPTd78Gyw/lDGW3Pz8MzXJ4pVDgr2UB7KN91/Rx9dJfN +3K04YR/TSpwB0Nug+5a1XuGh +-----END PRIVATE KEY----- diff --git a/test/suites/__init__.py b/test/suites/__init__.py index 1868ad42..406ca140 100644 --- a/test/suites/__init__.py +++ b/test/suites/__init__.py @@ -14,12 +14,13 @@ from .test_execute import TestSuite_Execute from .test_dbapi import TestSuite_DBAPI from .test_encoding import TestSuite_Encoding +from .test_ssl import TestSuite_Ssl test_cases = (TestSuite_Schema_UnicodeConnection, TestSuite_Schema_BinaryConnection, TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect, TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI, - TestSuite_Encoding, TestSuite_Pool) + TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl) def load_tests(loader, tests, pattern): suite = unittest.TestSuite() diff --git a/test/suites/box.lua b/test/suites/box.lua index 92114a8e..ecd12499 100644 --- a/test/suites/box.lua +++ b/test/suites/box.lua @@ -1,9 +1,13 @@ #!/usr/bin/env tarantool + local os = require('os') -require('console').listen(os.getenv("ADMIN_PORT")) +local admin_listen = os.getenv("ADMIN") +local primary_listen = os.getenv("LISTEN") + +require('console').listen(admin_listen) box.cfg{ - listen = os.getenv("PRIMARY_PORT"), + listen = primary_listen, memtx_memory = 0.1 * 1024^3, -- 0.1 GiB pid_file = "box.pid", } diff --git a/test/suites/lib/tarantool_server.py b/test/suites/lib/tarantool_server.py index 440a5d3c..2573a78a 100644 --- a/test/suites/lib/tarantool_server.py +++ b/test/suites/lib/tarantool_server.py @@ -14,6 +14,11 @@ from .tarantool_admin import TarantoolAdmin +from tarantool.const import ( + SSL_TRANSPORT +) + + def check_port(port, rais=True): try: sock = socket.create_connection(("localhost", port)) @@ -23,7 +28,8 @@ def check_port(port, rais=True): raise RuntimeError("The server is already running on port {0}".format(port)) return False -def find_port(port = None): + +def find_port(port=None): if port is None: port = random.randrange(3300, 9999) while port < 9999: @@ -113,13 +119,23 @@ def log_des(self): self._log_des.close() delattr(self, '_log_des') - def __new__(cls): + def __new__(cls, + transport=None, + ssl_key_file=None, + ssl_cert_file=None, + ssl_ca_file=None, + ssl_ciphers=None): if os.name == 'nt': from .remote_tarantool_server import RemoteTarantoolServer return RemoteTarantoolServer() return super(TarantoolServer, cls).__new__(cls) - def __init__(self): + def __init__(self, + transport=None, + ssl_key_file=None, + ssl_cert_file=None, + ssl_ca_file=None, + ssl_ciphers=None): os.popen('ulimit -c unlimited') self.host = 'localhost' self.args = {} @@ -129,6 +145,11 @@ def __init__(self): self.vardir = tempfile.mkdtemp(prefix='var_', dir=os.getcwd()) self.find_exe() self.process = None + 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 def find_exe(self): if 'TARANTOOL_BOX_PATH' in os.environ: @@ -140,9 +161,27 @@ def find_exe(self): return os.path.abspath(exe) raise RuntimeError("Can't find server executable in " + os.environ["PATH"]) + def generate_listen(self, port, port_only): + if not port_only and self.transport == SSL_TRANSPORT: + listen = self.host + ":" + str(port) + "?transport=ssl&" + if self.ssl_key_file: + listen += "ssl_key_file={}&".format(self.ssl_key_file) + if self.ssl_cert_file: + listen += "ssl_cert_file={}&".format(self.ssl_cert_file) + if self.ssl_ca_file: + listen += "ssl_ca_file={}&".format(self.ssl_ca_file) + if self.ssl_ciphers: + listen += "ssl_ciphers={}&".format(self.ssl_ciphers) + listen = listen[:-1] + else: + listen = str(port) + return listen + def generate_configuration(self): - os.putenv("PRIMARY_PORT", str(self.args['primary'])) - os.putenv("ADMIN_PORT", str(self.args['admin'])) + primary_listen = self.generate_listen(self.args['primary'], False) + admin_listen = self.generate_listen(self.args['admin'], True) + os.putenv("LISTEN", primary_listen) + os.putenv("ADMIN", admin_listen) def prepare_args(self): return shlex.split(self.binary if not self.script else self.script_dst) @@ -188,7 +227,7 @@ def start(self): os.chmod(self.script_dst, 0o777) args = self.prepare_args() self.process = subprocess.Popen(args, - cwd = self.vardir, + cwd=self.vardir, stdout=self.log_des, stderr=self.log_des) self.wait_until_started() diff --git a/test/suites/test_ssl.py b/test/suites/test_ssl.py new file mode 100644 index 00000000..c2d4ba2b --- /dev/null +++ b/test/suites/test_ssl.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- + +import os +import sys +import unittest +import warnings +from tarantool.error import ( + NetworkWarning +) +from tarantool.const import ( + DEFAULT_TRANSPORT, + SSL_TRANSPORT +) +import tarantool +from .lib.skip import skip_or_run_conn_pool_test +from .lib.tarantool_server import TarantoolServer + + +def is_test_ssl(): + env = os.getenv("TEST_TNT_SSL") + if env: + env = env.upper() + return env == "1" or env == "TRUE" + return False + + +@unittest.skipIf(not is_test_ssl(), "TEST_TNT_SSL is not set.") +class TestSuite_Ssl(unittest.TestCase): + @classmethod + def setUpClass(self): + print(' SSL '.center(70, '='), file=sys.stderr) + print('-' * 70, file=sys.stderr) + + test_suites_dir = os.path.dirname(__file__) + test_data_dir = os.path.join(test_suites_dir, "..", "data") + self.cert_file = os.path.join(test_data_dir, "localhost.crt") + self.invalidhost_cert_file = os.path.join(test_data_dir, + "invalidhost.crt") + self.key_file = os.path.join(test_data_dir, "localhost.key") + self.ca_file = os.path.join(test_data_dir, "ca.crt") + self.empty_file = os.path.join(test_data_dir, "empty") + self.invalid_file = "any_invalid_path" + + def stop_srv(self, srv): + if srv: + srv.stop() + srv.clean() + + def stop_con(self, con): + if con: + con.close() + + def stop_mesh(self, mesh): + if mesh: + mesh.stop() + + def stop_pool(self, pool): + if pool: + pool.close() + + def test_single(self): + class SslTestCase: + def __init__(self, + name="", + ok=False, + server_transport=SSL_TRANSPORT, + server_key_file=None, + server_cert_file=None, + server_ca_file=None, + server_ciphers=None, + client_cert_file=None, + client_key_file=None, + client_ca_file=None, + client_ciphers=None): + self.name = name + self.ok = ok + self.server_transport = server_transport + self.server_key_file = server_key_file + self.server_cert_file = server_cert_file + self.server_ca_file = server_ca_file + self.server_ciphers = server_ciphers + self.client_cert_file = client_cert_file + self.client_key_file = client_key_file + self.client_ca_file = client_ca_file + self.client_ciphers = client_ciphers + + # Requirements from Tarantool Enterprise Edition manual: + # https://www.tarantool.io/en/enterprise_doc/security/#configuration + # + # For a server: + # ssl_key_file - mandatory + # ssl_cert_file - mandatory + # ssl_ca_file - optional + # ssl_ciphers - optional + # + # For a client: + # ssl_key_file - optional, mandatory if server.CaFile set + # ssl_cert_file - optional, mandatory if server.CaFile set + # ssl_ca_file - optional + # ssl_ciphers - optional + testcases = [ + SslTestCase( + name="no_ssl_server", + ok=False, + server_transport=DEFAULT_TRANSPORT), + SslTestCase( + name="key_crt_server", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file), + SslTestCase( + name="key_crt_server_and_client", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file), + SslTestCase( + name="key_crt_ca_server", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_crt_client", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_cert_file=self.cert_file), + SslTestCase( + name="key_crt_ca_server_and_key_crt_client", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file), + SslTestCase( + name="key_crt_ca_server_and_client", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_invalidhost_crt_ca_server_and_key_crt_ca_client", + # A Tarantool implementation does not check hostname. It's + # the expected behavior. We don't do that too. + ok=True, + server_key_file=self.key_file, + server_cert_file=self.invalidhost_cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_client_invalid_crt", + ok=False, + client_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_key_file=self.key_file, + client_cert_file=self.invalid_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_client_invalid_key", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.invalid_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_client_invalid_ca", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.invalid_file), + SslTestCase( + name="key_crt_ca_server_and_client_empty_crt", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.empty_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_client_empty_key", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.empty_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_server_and_client_empty_ca", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.empty_file), + SslTestCase( + name="key_crt_ca_ciphers_server_and_key_crt_ca_client", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file), + SslTestCase( + name="key_crt_ca_ciphers_server_and_client", + ok=True, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file, + client_ciphers="ECDHE-RSA-AES256-GCM-SHA384"), + SslTestCase( + name="non_equal_ciphers", + ok=False, + server_key_file=self.key_file, + server_cert_file=self.cert_file, + server_ca_file=self.ca_file, + server_ciphers="ECDHE-RSA-AES256-GCM-SHA384", + client_key_file=self.key_file, + client_cert_file=self.cert_file, + client_ca_file=self.ca_file, + client_ciphers="TLS_AES_128_GCM_SHA256"), + ] + for t in testcases: + with self.subTest(msg=t.name): + srv = None + con = None + try: + srv = TarantoolServer( + transport=t.server_transport, + ssl_key_file=t.server_key_file, + ssl_cert_file=t.server_cert_file, + ssl_ca_file=t.server_ca_file, + ssl_ciphers=t.server_ciphers) + srv.script = 'test/suites/box.lua' + srv.start() + srv.admin("box.schema.create_space('space_1')") + srv.admin(""" + box.space['space_1']:create_index('primary', { + type = 'tree', + parts = {1, 'num'}, + unique = true}) + """.replace('\n', ' ')) + srv.admin(""" + box.schema.user.create('test', { password = 'test' }) + """.replace('\n', ' ')) + srv.admin(""" + box.schema.user.grant('test', 'execute,read,write', + 'universe') + """.replace('\n', ' ')) + con = tarantool.Connection( + srv.host, srv.args['primary'], + user="test", + password="test", + transport="ssl", + ssl_key_file=t.client_key_file, + ssl_cert_file=t.client_cert_file, + ssl_ca_file=t.client_ca_file, + ssl_ciphers=t.client_ciphers, + connection_timeout=0.5, + socket_timeout=0.5) + + self.assertEqual(con.insert('space_1', [1])[0], [1]) + self.assertEqual(len(con.select('space_1')), 1) + self.assertTrue(t.ok) + except tarantool.error.SslError: + self.assertFalse(t.ok) + finally: + self.stop_srv(srv) + self.stop_con(con) + + @unittest.skipIf(sys.platform.startswith("win"), + 'Pool tests on windows platform are not supported') + @skip_or_run_conn_pool_test + def test_pool(self): + servers = [] + cnt = 5 + pool = None + try: + addrs = [] + for i in range(cnt): + srv = TarantoolServer( + transport='ssl', + ssl_key_file=self.key_file, + ssl_cert_file=self.cert_file, + ssl_ca_file=self.ca_file) + srv.script = 'test/suites/box.lua' + srv.start() + srv.admin(""" + box.schema.user.create('test', { password = 'test' }) + """.replace('\n', ' ')) + srv.admin(""" + box.schema.user.grant('test', 'execute,read,write', 'universe') + """.replace('\n', ' ')) + servers.append(srv) + addrs.append({ + 'host': srv.host, + 'port': srv.args['primary'], + 'transport': 'ssl', + 'ssl_key_file': self.key_file, + 'ssl_cert_file': self.cert_file, + 'ssl_ca_file': self.ca_file}) + + pool = tarantool.ConnectionPool( + addrs=addrs, + user='test', + password='test', + connection_timeout=1, + socket_timeout=1) + self.assertSequenceEqual( + pool.eval('return box.info().ro', mode=tarantool.Mode.RW), + [False]) + finally: + self.stop_pool(pool) + + def test_mesh(self): + servers = [] + cnt = 5 + con = None + try: + addrs = [] + for i in range(cnt): + srv = TarantoolServer( + transport='ssl', + ssl_key_file=self.key_file, + ssl_cert_file=self.cert_file, + ssl_ca_file=self.ca_file) + srv.script = 'test/suites/box.lua' + srv.start() + srv.admin(""" + box.schema.user.create('test', { password = 'test' }) + """.replace('\n', ' ')) + srv.admin(""" + box.schema.user.grant('test', 'execute,read,write', 'universe') + """.replace('\n', ' ')) + srv.admin("function srv_id() return %s end" % i) + servers.append(srv) + addrs.append({ + 'host': srv.host, + 'port': srv.args['primary'], + 'transport': 'ssl', + 'ssl_key_file': self.key_file, + 'ssl_cert_file': self.cert_file, + 'ssl_ca_file': self.ca_file}) + + mesh = tarantool.MeshConnection( + addrs=addrs, + user='test', + password='test', + connection_timeout=0.5, + socket_timeout=0.5) + for i in range(cnt): + with warnings.catch_warnings(): + warnings.simplefilter('ignore', NetworkWarning) + resp = mesh.call('srv_id') + self.assertEqual(resp.data and resp.data[0], i) + servers[i].stop() + finally: + self.stop_mesh(con)