Compare commits

...

9 Commits

Author SHA1 Message Date
Vladimir Magamedov 637d751434 Updated docs for secure channels 2023-10-02 19:26:03 +03:00
Vladimir Magamedov c2dd5fd8ba Updated ReadTheDocs config 2023-10-02 19:01:29 +03:00
Vladimir Magamedov c2cfa57924 Increased timeout to fix flaky test 2023-10-02 18:31:01 +03:00
Vladimir Magamedov dc23a2377f Updated GitHub action versions 2023-10-02 18:09:40 +03:00
Vladimir Magamedov 1115d77c23 Implemented test for the Channel when you pass DefaultVerifyPaths to it 2023-10-02 18:09:40 +03:00
Vladimir Magamedov c74fd309f1 Implemented support for ssl.DefaultVerifyPaths as one of "ssl" argument options in client.Channel 2023-10-02 18:09:19 +03:00
Vladimir Magamedov 00febc0eee Updated mtls example 2023-10-02 18:09:19 +03:00
Vladimir Magamedov 9b0f8fe69e Tested ssl_target_name_override config option 2023-10-02 17:57:34 +03:00
Vladimir Magamedov 08b84209bf Implemented Configuration.ssl_target_name_override option 2023-10-02 17:57:25 +03:00
13 changed files with 162 additions and 62 deletions

View File

@ -6,12 +6,12 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v1
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements/release.txt') }} }}
- uses: actions/setup-python@v1
- uses: actions/setup-python@v4
with:
python-version: "3.7"
- run: pip3 install -r requirements/release.txt

View File

@ -6,12 +6,12 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v1
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements/check.txt') }}
- uses: actions/setup-python@v1
- uses: actions/setup-python@v4
with:
python-version: "3.7"
- run: pip3 install -r requirements/check.txt
@ -26,12 +26,12 @@ jobs:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v1
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ matrix.python-version }}-${{ hashFiles('requirements/test.txt') }}
- uses: actions/setup-python@v1
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- run: pip3 install -r requirements/test.txt

11
.readthedocs.yaml Normal file
View File

@ -0,0 +1,11 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.7"
sphinx:
configuration: docs/conf.py
python:
install:
- path: .
- requirements: requirements/docs.txt

View File

@ -1,6 +0,0 @@
requirements_file: requirements/docs.txt
build:
image: latest
python:
version: 3.7
pip_install: True

View File

@ -45,6 +45,9 @@ server:
server_streaming:
@PYTHONPATH=examples python3 -m streaming.server
server_mtls:
@PYTHONPATH=examples python3 -m mtls.server
_server:
@PYTHONPATH=examples python3 -m _reference.server
@ -54,5 +57,8 @@ client:
client_streaming:
@PYTHONPATH=examples python3 -m streaming.client
client_mtls:
@PYTHONPATH=examples python3 -m mtls.client
_client:
@PYTHONPATH=examples python3 -m _reference.client

View File

@ -59,13 +59,31 @@ Here is how to establish a secure connection to a public gRPC server:
In this case ``grpclib`` uses system CA certificates. But ``grpclib`` has also
a built-in support for a certifi_ package which contains actual Mozilla's
collection of CA certificates. All you need is to install it and keep it
updated -- this is a more favorable way than relying on system CA certificates:
collection of CA certificates. All you need is to install it and keep it up to
date -- this is a more favorable way than relying on system CA certificates:
.. code-block:: console
$ pip3 install certifi
Another way to tell which CA certificates to use is by using
:py:func:`python:ssl.get_default_verify_paths` function:
.. code-block:: python
channel = Channel(host, port, ssl=ssl.get_default_verify_paths())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This function also supports reading ``SSL_CERT_FILE`` and ``SSL_CERT_DIR``
environment variables to override your system defaults. It returns
``DefaultVerifyPaths`` named tuple structure which you can customize and provide
your own ``cafile`` and ``capath`` values without using environment variables or
placing certificates into a distribution-specific directory:
.. code-block:: python3
ssl.get_default_verify_paths()._replace(cafile=YOUR_CA_FILE)
``grpclib`` also allows you to use a custom SSL configuration by providing a
:py:class:`~python:ssl.SSLContext` object. We have a simple mTLS auth example
in our code repository to illustrate how this works.

View File

@ -21,17 +21,10 @@ CLIENT_KEY = DIR.joinpath('spock-imposter.key' if SPY_MODE else 'spock.key')
def create_secure_context(
client_cert: Path, client_key: Path, *, trusted: Path,
) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx = ssl.create_default_context(cafile=str(trusted))
ctx.load_cert_chain(str(client_cert), str(client_key))
ctx.load_verify_locations(str(trusted))
ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
ctx.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20')
ctx.set_alpn_protocols(['h2'])
try:
ctx.set_npn_protocols(['h2'])
except NotImplementedError:
pass
return ctx

View File

@ -3,7 +3,7 @@ mkfile_dir := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
keys:
rm -f $(mkfile_dir)*.key
rm -f $(mkfile_dir)*.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=mccoy.earth.svc.cluster.local' -keyout $(mkfile_dir)mccoy.key -out $(mkfile_dir)mccoy.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=mccoy.earth.svc.cluster.local' -keyout $(mkfile_dir)mccoy-imposter.key -out $(mkfile_dir)mccoy-imposter.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=spock.vulcan.svc.cluster.local' -keyout $(mkfile_dir)spock.key -out $(mkfile_dir)spock.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=spock.vulcan.svc.cluster.local' -keyout $(mkfile_dir)spock-imposter.key -out $(mkfile_dir)spock-imposter.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=localhost' -keyout $(mkfile_dir)mccoy.key -out $(mkfile_dir)mccoy.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=localhost' -keyout $(mkfile_dir)mccoy-imposter.key -out $(mkfile_dir)mccoy-imposter.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=localhost' -keyout $(mkfile_dir)spock.key -out $(mkfile_dir)spock.pem
openssl req -x509 -newkey rsa:2048 -nodes -subj '/CN=localhost' -keyout $(mkfile_dir)spock-imposter.key -out $(mkfile_dir)spock-imposter.pem

View File

@ -21,17 +21,14 @@ SERVER_KEY = DIR.joinpath('mccoy-imposter.key' if SPY_MODE else 'mccoy.key')
def create_secure_context(
server_cert: Path, server_key: Path, *, trusted: Path,
) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
ctx = ssl.create_default_context(
ssl.Purpose.CLIENT_AUTH,
cafile=str(trusted),
)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_cert_chain(str(server_cert), str(server_key))
ctx.load_verify_locations(str(trusted))
ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
ctx.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20')
ctx.set_alpn_protocols(['h2'])
try:
ctx.set_npn_protocols(['h2'])
except NotImplementedError:
pass
return ctx

View File

@ -621,7 +621,9 @@ class Channel:
path: Optional[str] = None,
codec: Optional[CodecBase] = None,
status_details_codec: Optional[StatusDetailsCodecBase] = None,
ssl: Union[None, bool, '_ssl.SSLContext'] = None,
ssl: Union[
None, bool, "_ssl.SSLContext", "_ssl.DefaultVerifyPaths"
] = None,
config: Optional[Configuration] = None,
):
"""Initialize connection to the server
@ -642,8 +644,9 @@ class Channel:
decode error details in a trailing metadata, if omitted
``ProtoStatusDetailsCodec`` is used by default
:param ssl: ``True`` or :py:class:`~python:ssl.SSLContext` object; if
``True``, default SSL context is used.
:param ssl: ``True`` or :py:class:`~python:ssl.SSLContext` object or
:py:class:`python:ssl.DefaultVerifyPaths` object; if ``True``,
default SSL context is used.
"""
if path is not None and (host is not None or port is not None):
raise ValueError("The 'path' parameter can not be used with the "
@ -655,8 +658,13 @@ class Channel:
if port is None:
port = 50051
if ssl is not None and _ssl is None:
raise RuntimeError('SSL is not supported.')
if ssl is True:
ssl = self._get_default_ssl_context()
elif isinstance(ssl, _ssl.DefaultVerifyPaths):
ssl = self._get_default_ssl_context(verify_paths=ssl)
if codec is None:
codec = ProtoCodec()
@ -705,12 +713,24 @@ class Channel:
async def _create_connection(self) -> H2Protocol:
if self._path is not None:
_, protocol = await self._loop.create_unix_connection(
self._protocol_factory, self._path, ssl=self._ssl,
self._protocol_factory,
self._path,
ssl=self._ssl,
server_hostname=(
self._config.ssl_target_name_override
if self._ssl is not None else None
),
)
else:
_, protocol = await self._loop.create_connection(
self._protocol_factory, self._host, self._port,
self._protocol_factory,
self._host,
self._port,
ssl=self._ssl,
server_hostname=(
self._config.ssl_target_name_override
if self._ssl is not None else None
),
)
return protocol
@ -734,27 +754,29 @@ class Channel:
return cast(H2Protocol, self._protocol)
# https://python-hyper.org/projects/h2/en/stable/negotiating-http2.html
def _get_default_ssl_context(self) -> '_ssl.SSLContext':
if _ssl is None:
raise RuntimeError('SSL is not supported.')
try:
import certifi
except ImportError:
cafile = None
def _get_default_ssl_context(
self, *, verify_paths: Optional['_ssl.DefaultVerifyPaths'] = None,
) -> '_ssl.SSLContext':
if verify_paths is not None:
cafile = verify_paths.cafile
capath = verify_paths.capath
else:
cafile = certifi.where()
try:
import certifi
except ImportError:
cafile = None
else:
cafile = certifi.where()
capath = None
ctx = _ssl.create_default_context(
purpose=_ssl.Purpose.SERVER_AUTH,
cafile=cafile,
capath=capath,
)
ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
ctx.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20')
ctx.set_alpn_protocols(['h2'])
if _ssl.HAS_NPN:
ctx.set_npn_protocols(['h2'])
return ctx
def request(

View File

@ -135,6 +135,13 @@ class Configuration:
},
)
#: NOTE: This should be used for testing only. Overrides the hostname that
#: the target servers certificate will be matched against. By default, the
#: value of the host argument is used.
ssl_target_name_override: Optional[str] = field(
default=None,
)
def __post_init__(self) -> None:
_validate(self)

View File

@ -1,24 +1,31 @@
import ssl
import sys
import asyncio
import tempfile
import contextlib
from unittest.mock import patch, ANY
import pytest
import certifi
from h2.connection import H2Connection
from grpclib.client import Channel
from grpclib.client import Channel, Handler
from grpclib.config import Configuration
from grpclib.protocol import H2Protocol
from grpclib.testing import ChannelFor
from dummy_pb2 import DummyRequest, DummyReply
from dummy_grpc import DummyServiceStub
from test_functional import DummyService
from stubs import TransportStub
@pytest.mark.asyncio
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Python < 3.8")
async def test_concurrent_connect(loop):
count = 5
reqs = [DummyRequest(value='ping') for _ in range(count)]
reps = [DummyReply(value='pong') for _ in range(count)]
reqs = [DummyRequest(value="ping") for _ in range(count)]
reps = [DummyReply(value="pong") for _ in range(count)]
channel = Channel()
@ -28,12 +35,18 @@ async def test_concurrent_connect(loop):
stub = DummyServiceStub(channel)
async with ChannelFor([DummyService()]) as _channel:
with patch.object(loop, 'create_connection') as po:
with patch.object(loop, "create_connection") as po:
po.side_effect = create_connection
tasks = [loop.create_task(stub.UnaryUnary(req)) for req in reqs]
replies = await asyncio.gather(*tasks)
assert replies == reps
po.assert_awaited_once_with(ANY, '127.0.0.1', 50051, ssl=None)
po.assert_awaited_once_with(
ANY,
"127.0.0.1",
50051,
ssl=None,
server_hostname=None,
)
def test_default_ssl_context():
@ -46,3 +59,42 @@ def test_default_ssl_context():
with patch.dict("sys.modules", {"certifi": None}):
system_channel = Channel(ssl=True)
assert system_channel._ssl
@pytest.mark.asyncio
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Python < 3.8")
async def test_ssl_target_name_override(loop):
config = Configuration(ssl_target_name_override="example.com")
async def create_connection(*args, **kwargs):
h2_conn = H2Connection()
transport = TransportStub(h2_conn)
protocol = H2Protocol(Handler(), config.__for_test__(), h2_conn.config)
protocol.connection_made(transport)
return None, protocol
with patch.object(loop, "create_connection") as po:
po.side_effect = create_connection
async with Channel(ssl=True, config=config) as channel:
await channel.__connect__()
po.assert_awaited_once_with(
ANY, ANY, ANY, ssl=channel._ssl, server_hostname="example.com"
)
def test_default_verify_paths():
with contextlib.ExitStack() as cm:
tf = cm.enter_context(tempfile.NamedTemporaryFile()).name
td = cm.enter_context(tempfile.TemporaryDirectory())
po = cm.enter_context(
patch.object(ssl.SSLContext, "load_verify_locations"),
)
cm.enter_context(
patch.dict("os.environ", SSL_CERT_FILE=tf, SSL_CERT_DIR=td),
)
default_verify_paths = ssl.get_default_verify_paths()
channel = Channel(ssl=default_verify_paths)
assert channel._ssl
po.assert_called_once_with(tf, td, None)
assert default_verify_paths.openssl_cafile_env == "SSL_CERT_FILE"
assert default_verify_paths.openssl_capath_env == "SSL_CERT_DIR"

View File

@ -5,7 +5,7 @@ import pytest
from h2.errors import ErrorCodes
from grpclib.const import Handler, Cardinality
from grpclib.const import Handler, Cardinality, Status
from grpclib.events import _DispatchServerEvents
from grpclib.server import request_handler
from grpclib.protocol import Connection, EventsProcessor
@ -168,7 +168,7 @@ async def test_deadline(
(':path', '/package.Service/Method'),
('te', 'trailers'),
('content-type', 'application/grpc'),
('grpc-timeout', '10m'),
('grpc-timeout', '50m'),
]
methods = {'/package.Service/Method': Handler(
handler,
@ -179,12 +179,12 @@ async def test_deadline(
task = loop.create_task(
call_handler(methods, stream, headers)
)
await asyncio.wait_for(task, 0.1)
await asyncio.wait_for(task, 0.1) # should be bigger than grpc-timeout
assert stream.__events__ == [
SendHeaders(headers=[
(':status', '200'),
('content-type', 'application/grpc+proto'),
('grpc-status', '4'), # DEADLINE_EXCEEDED
('grpc-status', str(Status.DEADLINE_EXCEEDED.value)),
], end_stream=True),
Reset(ErrorCodes.NO_ERROR),
]