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

WIP: Implement h2 protocol #1026

Closed
wants to merge 50 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
65dff56
Updated gitignore
Vibhu-Agarwal Apr 27, 2021
700046c
Added (empty) h2_impl.py
Vibhu-Agarwal Apr 27, 2021
773415e
Added some code ... till RequestReceived event
Vibhu-Agarwal Apr 29, 2021
159dd3b
Set basic triggers for event-handlers
Vibhu-Agarwal Apr 29, 2021
fef0bea
Fix on_request_received
Vibhu-Agarwal Apr 29, 2021
b92c359
Added remaining methods of H2Protocol
Vibhu-Agarwal Apr 29, 2021
1c5d053
Added code for RequestResponseCycle
Vibhu-Agarwal Apr 29, 2021
ec4b786
Added some boilerplate for handle_upgrade in h11_impl.py
Vibhu-Agarwal Apr 30, 2021
d2f329e
Handling h2c in h11_impl.py (TODO: Similarly in httptools)
Vibhu-Agarwal Apr 30, 2021
086cc29
Handling connection preface in h11_impl.py
Vibhu-Agarwal Apr 30, 2021
eb12592
Modify method prototype: H2Protocol - connection_made()
Vibhu-Agarwal Apr 30, 2021
d238f26
Fixed connection preface check in h11_impl.py
Vibhu-Agarwal Apr 30, 2021
e694792
Added comments regarding missing implementations
Vibhu-Agarwal Apr 30, 2021
00dce01
Reverted .gitignore
Vibhu-Agarwal Apr 30, 2021
c03b6e8
Added comments regarding missing implementations
Vibhu-Agarwal Apr 30, 2021
293aaf6
Applied black (linting)
Vibhu-Agarwal Apr 30, 2021
2a6f2dc
Reformatted h2_impl.py
Vibhu-Agarwal Apr 30, 2021
8f6a825
python -m cli_tools.usage
Vibhu-Agarwal Apr 30, 2021
394140a
Added h2 in requirements
Vibhu-Agarwal Apr 30, 2021
5d6dd1b
Added h2-specific SSLContext options in create_ssl_context()
Vibhu-Agarwal May 1, 2021
7e96009
Added h2-connection local settings (through config)
Vibhu-Agarwal May 1, 2021
3b5c906
h2c handling implementation added in h2_impl.py
Vibhu-Agarwal May 1, 2021
a8dcf14
Added ECDHE+AESGCM as SSL-Cipher-Suite for h2
Vibhu-Agarwal May 3, 2021
b683626
Added TODO for Websocket Extended CONNECT
Vibhu-Agarwal May 20, 2021
444a359
Merge branch 'master' into h2_impl
Vibhu-Agarwal May 27, 2021
4d4de11
Refactor w.r.t. #1034
Vibhu-Agarwal May 27, 2021
fb90043
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal May 28, 2021
1b2c1f2
Merge 'encode:master' into h2_impl
Vibhu-Agarwal May 29, 2021
dbfa251
Merge "encode:master" into h2_impl
Vibhu-Agarwal May 29, 2021
a0deef4
Remove irrelevant return statement
Vibhu-Agarwal May 29, 2021
301c171
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal May 30, 2021
96fe3d7
tests: add coverage for create_ssl_context [config.py]
Vibhu-Agarwal May 30, 2021
b16564b
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal May 31, 2021
e7e00db
Fix h2c bug in h11_impl.py
Vibhu-Agarwal May 31, 2021
0e7df0a
Refactor w.r.t. #869
Vibhu-Agarwal May 31, 2021
59c95bf
Fix bugs (similar to ones in previous commit)
Vibhu-Agarwal May 31, 2021
f7e5b8b
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal Jun 1, 2021
7cae838
auto upgrade (h11=>h2), when alpn_protocol == "h2"
Vibhu-Agarwal Jun 1, 2021
56140b9
Added a TODO for a probable implementation done in #929
Vibhu-Agarwal Jun 1, 2021
81f21be
Added a couple of EMPTY tests (h2c & PRI-h2)
Vibhu-Agarwal Jun 1, 2021
0e6ea51
Added test_h2.py with a simple POST request
Vibhu-Agarwal Jun 2, 2021
ad3f35f
Remove test_h2c_upgrade_request from test_http.py
Vibhu-Agarwal Jun 2, 2021
f4665bb
Fix the STUPID bug that has been freakin me out
Vibhu-Agarwal Jun 6, 2021
49f251a
Please fix!
Vibhu-Agarwal Jun 6, 2021
2741e0f
Remove "handle_upgrade()" from h2_impl.py
Vibhu-Agarwal Jun 6, 2021
df02d8c
on the way to fix h2c
Vibhu-Agarwal Jun 6, 2021
1e4bdd9
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal Jun 10, 2021
4766009
Merge branch 'encode:master' into h2_impl_mm1
Vibhu-Agarwal Jun 21, 2021
59d663a
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal Jun 21, 2021
919d3f3
Merge branch 'encode:master' into h2_impl
Vibhu-Agarwal Jun 23, 2021
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
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Options:
$WEB_CONCURRENCY environment variable if
available, or 1. Not valid with --reload.
--loop [auto|asyncio|uvloop] Event loop implementation. [default: auto]
--http [auto|h11|httptools] HTTP protocol implementation. [default:
--http [auto|h11|httptools|h2] HTTP protocol implementation. [default:
auto]
--ws [auto|none|websockets|wsproto]
WebSocket protocol implementation.
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Options:
$WEB_CONCURRENCY environment variable if
available, or 1. Not valid with --reload.
--loop [auto|asyncio|uvloop] Event loop implementation. [default: auto]
--http [auto|h11|httptools] HTTP protocol implementation. [default:
--http [auto|h11|httptools|h2] HTTP protocol implementation. [default:
auto]
--ws [auto|none|websockets|wsproto]
WebSocket protocol implementation.
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ types-pyyaml
trustme
cryptography
coverage
starlette
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need starlette? What's the idea here?

httpx==0.16.*
pytest-asyncio==0.14.*
async_generator; python_version < '3.7'
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def get_packages(package):
"asgiref>=3.3.4",
"click>=7.*",
"h11>=0.8",
"h2>=4.0.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"typing-extensions;" + env_marker_below_38,
]

Expand Down
41 changes: 41 additions & 0 deletions tests/protocols/test_h2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import httpx
import pytest
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

from tests.utils import run_server
from uvicorn.config import Config


async def homepage(request):
return JSONResponse({"hello": "world"})


app = Starlette(
routes=[
Route("/", homepage, methods=["GET", "POST"]),
],
)


@pytest.mark.asyncio
async def test_run(
tls_ca_ssl_context, tls_ca_certificate_pem_path, tls_ca_certificate_private_key_path
):
config = Config(
app=app,
http="h2",
loop="asyncio",
limit_max_requests=1,
ssl_keyfile=tls_ca_certificate_private_key_path,
ssl_certfile=tls_ca_certificate_pem_path,
ssl_ca_certs=tls_ca_certificate_pem_path,
)
async with run_server(config):
async with httpx.AsyncClient(verify=tls_ca_ssl_context, http2=True) as client:
response = await client.post(
"https://127.0.0.1:8000", data={"hello": "world"}
)
assert response.status_code == 200
assert response.http_version == "HTTP/2"
38 changes: 38 additions & 0 deletions tests/protocols/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@
]
)

UPGRADE_REQUEST_h2c = b"\r\n".join(
[
b"GET / HTTP/1.1",
b"Host: example.org",
b"Upgrade: h2c",
b"HTTP2-Settings: SomeHTTP2Setting",
b"",
b"",
]
)

UPGRADE_REQUEST_HTTP2_PRIOR = b"\r\n".join(
[
b"PRI * HTTP/2.0",
b"",
b"",
]
)


INVALID_REQUEST_TEMPLATE = b"\r\n".join(
[
b"%s",
Expand Down Expand Up @@ -683,6 +703,24 @@ def test_supported_upgrade_request(protocol_cls, event_loop):
assert b"HTTP/1.1 426 " in protocol.transport.buffer


# @pytest.mark.parametrize("protocol_cls", [H11Protocol])
# def test_h2c_upgrade_request(protocol_cls, event_loop):
# app = Response("Hello, world", media_type="text/plain")
#
# with get_connected_protocol(app, protocol_cls, event_loop) as protocol:
# protocol.data_received(UPGRADE_REQUEST_h2c)
# # TODO: check h2c_upgrade_request response


@pytest.mark.parametrize("protocol_cls", [H11Protocol])
def test_h2_prior_upgrade_request(protocol_cls, event_loop):
app = Response("Hello, world", media_type="text/plain")

with get_connected_protocol(app, protocol_cls, event_loop) as protocol:
protocol.data_received(UPGRADE_REQUEST_HTTP2_PRIOR)
# TODO: check h2_prior_upgrade_request response


async def asgi3app(scope, receive, send):
pass

Expand Down
12 changes: 12 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ def test_ssl_config_combined(tls_certificate_pem_path: str) -> None:
assert config.is_ssl is True


def test_ssl_config_h2(tls_certificate_pem_path):
config = Config(
app=asgi_app,
http="h2",
ssl_certfile=tls_certificate_pem_path,
)
config.load()

assert config.is_ssl is True
# TODO: Should we also check HTTP/2-Specific 'ciphers' and 'options' here?


def asgi2_app(scope: Scope) -> typing.Callable:
async def asgi(
receive: ASGIReceiveCallable, send: ASGISendCallable
Expand Down
6 changes: 6 additions & 0 deletions uvicorn/_handlers/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ async def handle_http(
loop = asyncio.get_event_loop()
connection_lost = loop.create_future()

ssl_object = writer.get_extra_info("ssl_object")
if ssl_object is not None:
alpn_protocol = ssl_object.selected_alpn_protocol()
if alpn_protocol == "h2":
config.http_protocol_class = config.h2_protocol_class

# Switch the protocol from the stream reader to our own HTTP protocol class.
protocol = config.http_protocol_class( # type: ignore[call-arg, operator]
config=config,
Expand Down
32 changes: 31 additions & 1 deletion uvicorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"auto": "uvicorn.protocols.http.auto:AutoHTTPProtocol",
"h11": "uvicorn.protocols.http.h11_impl:H11Protocol",
"httptools": "uvicorn.protocols.http.httptools_impl:HttpToolsProtocol",
"h2": "uvicorn.protocols.http.h2_impl:H2Protocol",
}
WS_PROTOCOLS: Dict[WSProtocolType, Optional[str]] = {
"auto": "uvicorn.protocols.websockets.auto:AutoWebSocketsProtocol",
Expand Down Expand Up @@ -121,6 +122,7 @@ def create_ssl_context(
cert_reqs: int,
ca_certs: Optional[Union[str, os.PathLike]],
ciphers: Optional[str],
enable_h2: Optional[bool],
) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl_version)
get_password = (lambda: password) if password else None
Expand All @@ -130,6 +132,20 @@ def create_ssl_context(
ctx.load_verify_locations(ca_certs)
if ciphers:
ctx.set_ciphers(ciphers)
if enable_h2:
ctx.options |= (
ssl.OP_NO_SSLv2
| ssl.OP_NO_SSLv3
| ssl.OP_NO_TLSv1
| ssl.OP_NO_TLSv1_1
| ssl.OP_NO_COMPRESSION
| ssl.OP_CIPHER_SERVER_PREFERENCE
)
ctx.set_alpn_protocols(["h2", "http/1.1"])
try:
ctx.set_npn_protocols(["h2", "http/1.1"])
except NotImplementedError:
pass
return ctx


Expand Down Expand Up @@ -221,6 +237,11 @@ def __init__(
self.headers: List[List[str]] = headers or []
self.encoded_headers: Optional[List[Tuple[bytes, bytes]]] = None
self.factory = factory
self.h2_protocol_class = None
self.h2_max_concurrent_streams = 100
self.h2_max_header_list_size = 2 ** 16
self.h2_max_inbound_frame_size = 2 ** 14
self.h2_ssl_ciphers = "ECDHE+AESGCM"

self.loaded = False
self.configure_logging()
Expand Down Expand Up @@ -305,6 +326,12 @@ def configure_logging(self) -> None:
def load(self) -> None:
assert not self.loaded

enable_h2 = self.http in ("h2",)
ciphers = (
self.h2_ssl_ciphers
if (self.ssl_ciphers == "TLSv1" and enable_h2)
else self.ssl_ciphers
)
if self.is_ssl:
assert self.ssl_certfile
self.ssl: Optional[ssl.SSLContext] = create_ssl_context(
Expand All @@ -314,7 +341,8 @@ def load(self) -> None:
ssl_version=self.ssl_version,
cert_reqs=self.ssl_cert_reqs,
ca_certs=self.ssl_ca_certs,
ciphers=self.ssl_ciphers,
ciphers=ciphers,
enable_h2=enable_h2,
)
else:
self.ssl = None
Expand Down Expand Up @@ -343,6 +371,8 @@ def load(self) -> None:

self.lifespan_class = import_from_string(LIFESPAN[self.lifespan])

self.h2_protocol_class = import_from_string(HTTP_PROTOCOLS["h2"])

try:
self.loaded_app = import_from_string(self.app)
except ImportFromStringError as exc:
Expand Down
91 changes: 71 additions & 20 deletions uvicorn/protocols/http/h11_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ def handle_events(self):
break

elif event_type is h11.Request:

if self.check_connection_preface(event):
return

self.headers = [(key.lower(), value) for key, value in event.headers]
raw_path, _, query_string = event.target.partition(b"?")
self.scope = {
Expand All @@ -179,6 +183,9 @@ def handle_events(self):
if b"upgrade" in tokens:
self.handle_upgrade(event)
return
elif name == b"upgrade" and value.lower() == b"h2c":
self.handle_upgrade(event)
return

# Handle 503 responses when 'limit_concurrency' is exceeded.
if self.limit_concurrency is not None and (
Expand Down Expand Up @@ -223,13 +230,76 @@ def handle_events(self):
self.cycle.more_body = False
self.cycle.message_event.set()

def check_connection_preface(self, event):
if (
event.method == b"PRI"
and event.target == b"*"
and event.http_version == b"2.0"
):
# https://tools.ietf.org/html/rfc7540#section-3.5
self.connections.discard(self)

protocol = self.config.h2_protocol_class(
config=self.config,
server_state=self.server_state,
on_connection_lost=self.on_connection_lost,
)
protocol.connection_made(self.transport)
self.transport.set_protocol(protocol)
protocol.data_received(
b"PRI * HTTP/2.0\r\n\r\n" + self.conn.trailing_data[0]
)
return True
return False

def handle_upgrade(self, event):
upgrade_value = None
has_body = False
for name, value in self.headers:
if name == b"upgrade":
upgrade_value = value.lower()
elif name in {"content-length", "transfer-encoding"}:
has_body = True

if upgrade_value == b"h2c" and not has_body:
self.connections.discard(self)

headers = ((b"upgrade", b"h2c"), *self.headers)
self.transport.write(
self.conn.send(
h11.InformationalResponse(status_code=101, headers=headers)
)
)
protocol = self.config.h2_protocol_class(
config=self.config,
server_state=self.server_state,
on_connection_lost=self.on_connection_lost,
)
protocol.connection_made(self.transport, upgrade_request=event)
self.transport.set_protocol(protocol)
request_data = self.conn.trailing_data[0]
if request_data != b"":
protocol.data_received(request_data)

elif upgrade_value == b"websocket" and self.ws_protocol_class is not None:
if self.logger.level <= TRACE_LOG_LEVEL:
prefix = "%s:%d - " % tuple(self.client) if self.client else ""
self.logger.log(TRACE_LOG_LEVEL, "%sUpgrading to WebSocket", prefix)
self.connections.discard(self)
output = [event.method, b" ", event.target, b" HTTP/1.1\r\n"]
for name, value in self.headers:
output += [name, b": ", value, b"\r\n"]
output.append(b"\r\n")
protocol = self.ws_protocol_class(
config=self.config,
server_state=self.server_state,
on_connection_lost=self.on_connection_lost,
)
protocol.connection_made(self.transport)
protocol.data_received(b"".join(output))
self.transport.set_protocol(protocol)

if upgrade_value != b"websocket" or self.ws_protocol_class is None:
else:
msg = "Unsupported upgrade request."
self.logger.warning(msg)

Expand All @@ -254,25 +324,6 @@ def handle_upgrade(self, event):
output = self.conn.send(event)
self.transport.write(output)
self.transport.close()
return

if self.logger.level <= TRACE_LOG_LEVEL:
prefix = "%s:%d - " % tuple(self.client) if self.client else ""
self.logger.log(TRACE_LOG_LEVEL, "%sUpgrading to WebSocket", prefix)

self.connections.discard(self)
output = [event.method, b" ", event.target, b" HTTP/1.1\r\n"]
for name, value in self.headers:
output += [name, b": ", value, b"\r\n"]
output.append(b"\r\n")
protocol = self.ws_protocol_class(
config=self.config,
server_state=self.server_state,
on_connection_lost=self.on_connection_lost,
)
protocol.connection_made(self.transport)
protocol.data_received(b"".join(output))
self.transport.set_protocol(protocol)

def on_response_complete(self):
self.server_state.total_requests += 1
Expand Down
Loading