Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSL support #220

Merged
merged 2 commits into from
Jun 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 87 additions & 5 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
DifferentialOrange marked this conversation as resolved.
Show resolved Hide resolved
- '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

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

DifferentialOrange marked this conversation as resolved.
Show resolved Hide resolved
### 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
Totktonada marked this conversation as resolved.
Show resolved Hide resolved
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()
Totktonada marked this conversation as resolved.
Show resolved Hide resolved
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
Loading