Compare commits
9 Commits
0b9b171c8d
...
637d751434
Author | SHA1 | Date |
---|---|---|
Vladimir Magamedov | 637d751434 | |
Vladimir Magamedov | c2dd5fd8ba | |
Vladimir Magamedov | c2cfa57924 | |
Vladimir Magamedov | dc23a2377f | |
Vladimir Magamedov | 1115d77c23 | |
Vladimir Magamedov | c74fd309f1 | |
Vladimir Magamedov | 00febc0eee | |
Vladimir Magamedov | 9b0f8fe69e | |
Vladimir Magamedov | 08b84209bf |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
requirements_file: requirements/docs.txt
|
||||
build:
|
||||
image: latest
|
||||
python:
|
||||
version: 3.7
|
||||
pip_install: True
|
6
Makefile
6
Makefile
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -135,6 +135,13 @@ class Configuration:
|
|||
},
|
||||
)
|
||||
|
||||
#: NOTE: This should be used for testing only. Overrides the hostname that
|
||||
#: the target server’s 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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue