Browse Source

WIP Websocket over HTTP/3 is working!!

Loïc Hoguin 1 year ago
parent
commit
fff518b723

+ 10 - 1
src/cow_http3.erl

@@ -240,6 +240,13 @@ parse_settings_id_val(Rest, Len, Settings, Identifier, Value) ->
 		_ when Identifier < 6, Identifier =/= 1 ->
 			{connection_error, h3_settings_error,
 				'HTTP/2 setting not defined for HTTP/3 must be rejected. (RFC9114 7.2.4.1)'};
+		8 when Value =:= 0 ->
+			parse_settings_key_val(Rest, Len, Settings, enable_connect_protocol, false);
+		8 when Value =:= 1 ->
+			parse_settings_key_val(Rest, Len, Settings, enable_connect_protocol, true);
+		8 ->
+			{connection_error, h3_settings_error,
+				'The SETTINGS_ENABLE_CONNECT_PROTOCOL value MUST be 0 or 1. (RFC9220 3, RFC8441 3)'};
 		%% Unknown settings must be ignored.
 		_ ->
 			parse_settings_id(Rest, Len, Settings)
@@ -335,7 +342,9 @@ settings(Settings) ->
 settings_payload(Settings) ->
 	Payload = [case Key of
 		max_header_list_size when Value =:= infinity -> <<>>;
-		max_header_list_size -> [encode_int(6), encode_int(Value)]
+		max_header_list_size -> [encode_int(6), encode_int(Value)];
+		enable_connect_protocol when Value -> [encode_int(8), encode_int(1)];
+		enable_connect_protocol -> [encode_int(8), encode_int(0)]
 	end || {Key, Value} <- maps:to_list(Settings)],
 	%% Include one reserved identifier in addition.
 	ReservedType = 16#1f * (rand:uniform(148764065110560900) - 1) + 16#21,

+ 33 - 14
src/cow_http3_machine.erl

@@ -65,6 +65,13 @@
 	%% Maximum Push ID.
 	max_push_id = -1 :: -1 | non_neg_integer(),
 
+	%% Settings are separate for each endpoint. They are sent once
+	%% at the beginning of the control stream.
+	local_settings = #{
+%		max_field_section_size => infinity
+%		enable_connect_protocol => false
+	} :: map(),
+
 	%% Currently active HTTP/3 streams. Streams may be initiated either
 	%% by the client or by the server through PUSH_PROMISE frames.
 	streams = #{} :: #{non_neg_integer() => stream()},
@@ -79,8 +86,20 @@
 
 -spec init(_, _) -> _. %% @todo
 
-init(Mode, _Opts) ->
-	{ok, cow_http3:settings(#{}), #http3_machine{mode=Mode}}.
+init(Mode, Opts) ->
+	Settings = settings_init(Opts),
+	{ok, cow_http3:settings(Settings),
+		#http3_machine{mode=Mode, local_settings=Settings}}.
+
+settings_init(Opts) ->
+	S0 = setting_from_opt(#{}, Opts, max_field_section_size, infinity),
+	setting_from_opt(S0, Opts, enable_connect_protocol, false).
+
+setting_from_opt(Settings, Opts, Name, Default) ->
+	case maps:get(Name, Opts, Default) of
+		Default -> Settings;
+		Value -> Settings#{Name => Value}
+	end.
 
 -spec init_unidi_local_streams(_, _ ,_ ,_) -> _. %% @todo
 
@@ -246,23 +265,23 @@ headers_decode({headers, EncodedFieldSection}, IsFin, Stream=#stream{id=StreamID
 
 %% @todo Much of the headers handling past this point is common between h2 and h3.
 
-headers_pseudo_headers(Stream, State,%=#http3_machine{local_settings=LocalSettings},
+headers_pseudo_headers(Stream, State=#http3_machine{local_settings=LocalSettings},
 		IsFin, Type, DecData, Headers0) when Type =:= request ->%; Type =:= push_promise ->
-%	IsExtendedConnectEnabled = maps:get(enable_connect_protocol, LocalSettings, false),
+	IsExtendedConnectEnabled = maps:get(enable_connect_protocol, LocalSettings, false),
 	case request_pseudo_headers(Headers0, #{}) of
 		%% Extended CONNECT method (RFC9220).
-%		{ok, PseudoHeaders=#{method := <<"CONNECT">>, scheme := _,
-%			authority := _, path := _, protocol := _}, Headers}
-%			when IsExtendedConnectEnabled ->
-%			headers_regular_headers(Frame, State, Type, Stream, PseudoHeaders, Headers);
-%		{ok, #{method := <<"CONNECT">>, scheme := _,
-%			authority := _, path := _}, _}
-%			when IsExtendedConnectEnabled ->
-%			headers_malformed(Stream, State,
-%				'The :protocol pseudo-header MUST be sent with an extended CONNECT. (RFC8441 4)');
+		{ok, PseudoHeaders=#{method := <<"CONNECT">>, scheme := _,
+			authority := _, path := _, protocol := _}, Headers}
+			when IsExtendedConnectEnabled ->
+			headers_regular_headers(Stream, State, IsFin, Type, DecData, PseudoHeaders, Headers);
+		{ok, #{method := <<"CONNECT">>, scheme := _,
+			authority := _, path := _}, _}
+			when IsExtendedConnectEnabled ->
+			headers_malformed(Stream, State,
+				'The :protocol pseudo-header MUST be sent with an extended CONNECT. (RFC9220, RFC8441 4)');
 		{ok, #{protocol := _}, _} ->
 			headers_malformed(Stream, State,
-				'The :protocol pseudo-header is only defined for the extended CONNECT. (RFC8441 4)');
+				'The :protocol pseudo-header is only defined for the extended CONNECT. (RFC9220, RFC8441 4)');
 		%% Normal CONNECT (no scheme/path).
 		{ok, PseudoHeaders=#{method := <<"CONNECT">>, authority := _}, Headers}
 				when map_size(PseudoHeaders) =:= 2 ->

+ 24 - 2
src/cowboy.erl

@@ -70,13 +70,18 @@ start_tls(Ref, TransOpts0, ProtoOpts0) ->
 start_quic(TransOpts, ProtoOpts) ->
 	{ok, _} = application:ensure_all_started(quicer),
 	Parent = self(),
-	Port = 4567,
 	SocketOpts0 = maps:get(socket_opts, TransOpts, []),
+	{Port, SocketOpts2} = case lists:keytake(port, 1, SocketOpts0) of
+		{value, {port, Port0}, SocketOpts1} ->
+			{Port0, SocketOpts1};
+		false ->
+			{port_0(), SocketOpts0}
+	end,
 	SocketOpts = [
 		{alpn, ["h3"]}, %% @todo Why not binary?
 		{peer_unidi_stream_count, 3}, %% We only need control and QPACK enc/dec.
 		{peer_bidi_stream_count, 100}
-	|SocketOpts0],
+	|SocketOpts2],
 	{ok, Listener} = quicer:listen(Port, SocketOpts),
 	ct:pal("listen ~p", [Listener]),
 	_ListenerPid = spawn(fun AcceptLoop() ->
@@ -99,6 +104,23 @@ start_quic(TransOpts, ProtoOpts) ->
 	end),
 	{ok, Listener}.
 
+%% Select a random UDP port using gen_udp because quicer
+%% does not provide equivalent functionality. Taken from
+%% quicer test suites.
+port_0() ->
+	{ok, Socket} = gen_udp:open(0, [{reuseaddr, true}]),
+	{ok, {_, Port}} = inet:sockname(Socket),
+	gen_udp:close(Socket),
+	case os:type() of
+		{unix, darwin} ->
+			%% Apparently macOS doesn't free the port immediately.
+			timer:sleep(500);
+		_ ->
+			ok
+	end,
+	ct:pal("port_0: ~p", [Port]),
+	Port.
+
 -spec start_quic_test() -> ok.
 start_quic_test() ->
 	start_quic(#{

+ 12 - 16
src/cowboy_http3.erl

@@ -338,7 +338,7 @@ headers_frame(State, Stream, IsFin, Headers, PseudoHeaders, BodyLen) ->
 
 headers_frame_parse_host(State=#state{peer=Peer, sock=Sock},
 		Stream=#stream{id=StreamID}, IsFin, Headers,
-		#{method := Method, scheme := Scheme, path := PathWithQs},
+		PseudoHeaders=#{method := Method, scheme := Scheme, path := PathWithQs},
 		BodyLen, Authority) ->
 	try cow_http_hd:parse_host(Authority) of
 		{Host, Port0} ->
@@ -348,7 +348,7 @@ headers_frame_parse_host(State=#state{peer=Peer, sock=Sock},
 					reset_stream(State, Stream, {stream_error, h3_message_error,
 						'The path component must not be empty. (RFC7540 8.1.2.3)'});
 				{Path, Qs} ->
-					Req = #{
+					Req0 = #{
 						ref => quic, %% @todo Ref,
 						pid => self(),
 						streamid => StreamID,
@@ -366,11 +366,11 @@ headers_frame_parse_host(State=#state{peer=Peer, sock=Sock},
 						has_body => IsFin =:= nofin,
 						body_length => BodyLen
 					},
-					%% We add the protocol information for extended CONNECTs. @todo
-%					Req = case PseudoHeaders of
-%						#{protocol := Protocol} -> Req1#{protocol => Protocol};
-%						_ -> Req1
-%					end,
+					%% We add the protocol information for extended CONNECTs.
+					Req = case PseudoHeaders of
+						#{protocol := Protocol} -> Req0#{protocol => Protocol};
+						_ -> Req0
+					end,
 					headers_frame(State, Stream, Req)
 			catch _:_ ->
 				reset_stream(State, Stream, {stream_error, h3_message_error,
@@ -627,17 +627,13 @@ commands(State, Stream, [Error = {internal_error, _, _}|_Tail]) ->
 	%% @todo Do we even allow commands after?
 	%% @todo Only reset when the stream still exists.
 	reset_stream(State, Stream, Error);
-%% Upgrade to HTTP/2. This is triggered by cowboy_http2 itself.
-%commands(State=#state{socket=Socket, transport=Transport, http2_status=upgrade},
-%		Stream, [{switch_protocol, Headers, ?MODULE, _}|Tail]) ->
-%	%% @todo This 101 response needs to be passed through stream handlers.
-%	Transport:send(Socket, cow_http:response(101, 'HTTP/1.1', maps:to_list(Headers))),
-%	commands(State, Stream, Tail);
 %% Use a different protocol within the stream (CONNECT :protocol).
 %% @todo Make sure we error out when the feature is disabled.
-%commands(State0, Stream, [{switch_protocol, Headers, _Mod, _ModState}|Tail]) ->
-%	State = info(State0, Stream, {headers, 200, Headers}),
-%	commands(State, Stream, Tail);
+commands(State0, Stream0=#stream{id=StreamID},
+		[{switch_protocol, Headers, _Mod, _ModState}|Tail]) ->
+	State = info(stream_store(State0, Stream0), StreamID, {headers, 200, Headers}),
+	Stream = stream_get(State, StreamID),
+	commands(State, Stream, Tail);
 %% Set options dynamically.
 commands(State, Stream, [{set_options, _Opts}|Tail]) ->
 	commands(State, Stream, Tail);

+ 11 - 7
src/cowboy_websocket.erl

@@ -103,7 +103,8 @@
 %% is trying to upgrade to the Websocket protocol.
 
 -spec is_upgrade_request(cowboy_req:req()) -> boolean().
-is_upgrade_request(#{version := 'HTTP/2', method := <<"CONNECT">>, protocol := Protocol}) ->
+is_upgrade_request(#{version := Version, method := <<"CONNECT">>, protocol := Protocol})
+		when Version =:= 'HTTP/2'; Version =:= 'HTTP/3' ->
 	<<"websocket">> =:= cowboy_bstr:to_lower(Protocol);
 is_upgrade_request(Req=#{version := 'HTTP/1.1', method := <<"GET">>}) ->
 	ConnTokens = cowboy_req:parse_header(<<"connection">>, Req, []),
@@ -148,13 +149,13 @@ upgrade(Req0=#{version := Version}, Env, Handler, HandlerState, Opts) ->
 				<<"connection">> => <<"upgrade">>,
 				<<"upgrade">> => <<"websocket">>
 			}, Req0), Env};
-		%% Use a generic 400 error for HTTP/2.
+		%% Use 501 Not Implemented for HTTP/2 and HTTP/3 as recommended
+		%% by RFC9220 3 (WebSockets Upgrade over HTTP/3).
 		{error, upgrade_required} ->
-			{ok, cowboy_req:reply(400, Req0), Env}
+			{ok, cowboy_req:reply(501, Req0), Env}
 	catch _:_ ->
 		%% @todo Probably log something here?
 		%% @todo Test that we can have 2 /ws 400 status code in a row on the same connection.
-		%% @todo Does this even work?
 		{ok, cowboy_req:reply(400, Req0), Env}
 	end.
 
@@ -286,9 +287,12 @@ websocket_handshake(State, Req=#{ref := Ref, pid := Pid, streamid := StreamID},
 	module() | undefined, any(), binary(),
 	{#state{}, any()}) -> no_return().
 takeover(Parent, Ref, Socket, Transport, _Opts, Buffer,
-		{State0=#state{handler=Handler}, HandlerState}) ->
-	%% @todo We should have an option to disable this behavior.
-	ranch:remove_connection(Ref),
+		{State0=#state{handler=Handler, req=Req}, HandlerState}) ->
+	case Req of
+		#{version := 'HTTP/3'} -> ok;
+		%% @todo We should have an option to disable this behavior.
+		_ -> ranch:remove_connection(Ref)
+	end,
 	Messages = case Transport of
 		undefined -> undefined;
 		_ -> Transport:messages()

+ 1 - 1
test/cowboy_test.erl

@@ -39,7 +39,6 @@ init_http2(Ref, ProtoOpts, Config) ->
 
 %% @todo This will probably require TransOpts as argument.
 init_http3(Ref, ProtoOpts, Config) ->
-	Port = 4567,
 	%% @todo Quicer does not currently support non-file cert/key,
 	%%       so we use quicer test certificates for now.
 	%% @todo Quicer also does not support cacerts which means
@@ -53,6 +52,7 @@ init_http3(Ref, ProtoOpts, Config) ->
 		]
 	},
 	{ok, Listener} = cowboy:start_quic(TransOpts, ProtoOpts), %% @todo Ref argument.
+	{ok, {_, Port}} = quicer:sockname(Listener),
 	%% @todo Keep listener information around in a better place.
 	persistent_term:put({cowboy_test_quic, Ref}, Listener),
 	[{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config].

+ 8 - 5
test/rfc8441_SUITE.erl

@@ -126,6 +126,7 @@ reject_handshake_disabled_by_default(Config0) ->
 
 % The Extended CONNECT Method.
 
+%% @todo Refer to RFC9110 7.8 about the case insensitive comparison.
 accept_uppercase_pseudo_header_protocol(Config) ->
 	doc("The :protocol pseudo header is case insensitive. (draft-01 4)"),
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -172,6 +173,7 @@ reject_many_pseudo_header_protocol(Config) ->
 	ok.
 
 reject_unknown_pseudo_header_protocol(Config) ->
+	%% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220.
 	doc("An extended CONNECT request with an unknown protocol must be rejected "
 		"with a 400 error. (draft-01 4)"),
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -192,10 +194,11 @@ reject_unknown_pseudo_header_protocol(Config) ->
 	{ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000),
 	{ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000),
 	{RespHeaders, _} = cow_hpack:decode(RespHeadersBlock),
-	{_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
+	{_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
 	ok.
 
 reject_invalid_pseudo_header_protocol(Config) ->
+	%% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220.
 	doc("An extended CONNECT request with an invalid protocol must be rejected "
 		"with a 400 error. (draft-01 4)"),
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
@@ -216,7 +219,7 @@ reject_invalid_pseudo_header_protocol(Config) ->
 	{ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000),
 	{ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000),
 	{RespHeaders, _} = cow_hpack:decode(RespHeadersBlock),
-	{_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
+	{_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders),
 	ok.
 
 reject_missing_pseudo_header_scheme(Config) ->
@@ -293,7 +296,7 @@ reject_missing_pseudo_header_protocol(Config) ->
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
 	{ok, Socket, Settings} = do_handshake(Config),
 	#{enable_connect_protocol := true} = Settings,
-	%% Send an extended CONNECT request without a :scheme pseudo-header.
+	%% Send an extended CONNECT request without a :protocol pseudo-header.
 	{ReqHeadersBlock, _} = cow_hpack:encode([
 		{<<":method">>, <<"CONNECT">>},
 		{<<":scheme">>, <<"http">>},
@@ -317,7 +320,7 @@ reject_connection_header(Config) ->
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
 	{ok, Socket, Settings} = do_handshake(Config),
 	#{enable_connect_protocol := true} = Settings,
-	%% Send an extended CONNECT request without a :scheme pseudo-header.
+	%% Send an extended CONNECT request with a connection header.
 	{ReqHeadersBlock, _} = cow_hpack:encode([
 		{<<":method">>, <<"CONNECT">>},
 		{<<":protocol">>, <<"websocket">>},
@@ -339,7 +342,7 @@ reject_upgrade_header(Config) ->
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
 	{ok, Socket, Settings} = do_handshake(Config),
 	#{enable_connect_protocol := true} = Settings,
-	%% Send an extended CONNECT request without a :scheme pseudo-header.
+	%% Send an extended CONNECT request with a upgrade header.
 	{ReqHeadersBlock, _} = cow_hpack:encode([
 		{<<":method">>, <<"CONNECT">>},
 		{<<":protocol">>, <<"websocket">>},

+ 19 - 12
test/rfc9114_SUITE.erl

@@ -112,7 +112,7 @@ req_stream(Config) ->
 		<<":status">> := <<"200">>,
 		<<"content-length">> := BodyLen
 	} = maps:from_list(DecodedResponse),
-	{DLenEnc, DLenBits} = do_guess_int_encoding(Data),
+	{DLenEnc, DLenBits} = do_guess_int_encoding(Rest),
 	<<
 		0, %% DATA frame.
 		DLenEnc:2, DLen:DLenBits,
@@ -2244,10 +2244,11 @@ do_connect(Config, Opts) ->
 		Opts#{alpn => ["h3"], verify => none}, 5000),
 	%% To make sure the connection is fully established we wait
 	%% to receive the SETTINGS frame on the control stream.
-	{ok, ControlRef, _Settings} = do_wait_settings(Conn),
+	{ok, ControlRef, Settings} = do_wait_settings(Conn),
 	#{
 		conn => Conn,
-		control => ControlRef %% This is the peer control stream.
+		control => ControlRef, %% This is the peer control stream.
+		settings => Settings
 	}.
 
 do_wait_settings(Conn) ->
@@ -2257,9 +2258,10 @@ do_wait_settings(Conn) ->
 			true = quicer:is_unidirectional(Flags),
 			receive {quic, <<
 				0, %% Control stream.
-				4, 0 %% Empty SETTINGS frame.
+				SettingsFrame/bits
 			>>, StreamRef, _} ->
-				{ok, StreamRef, #{}}
+				{ok, {settings, Settings}, <<>>} = cow_http3:parse(SettingsFrame),
+				{ok, StreamRef, Settings}
 			after 5000 ->
 				{error, timeout}
 			end
@@ -2330,13 +2332,18 @@ do_receive_response(StreamRef) ->
 		= cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init()),
 	Headers = maps:from_list(DecodedResponse),
 	#{<<"content-length">> := BodyLen} = Headers,
-	{DLenEnc, DLenBits} = do_guess_int_encoding(Data),
-	<<
-		0, %% DATA frame.
-		DLenEnc:2, DLen:DLenBits,
-		Body:DLen/bytes
-	>> = Rest,
-	BodyLen = integer_to_binary(byte_size(Body)),
+	{DLenEnc, DLenBits} = do_guess_int_encoding(Rest),
+	Body = case Rest of
+		<<>> ->
+			<<>>;
+		<<
+			0, %% DATA frame.
+			DLenEnc:2, DLen:DLenBits,
+			Body0:DLen/bytes
+		>> ->
+			BodyLen = integer_to_binary(byte_size(Body0)),
+			Body0
+	end,
 	ok = do_wait_peer_send_shutdown(StreamRef),
 	#{
 		headers => Headers,

+ 484 - 0
test/rfc9220_SUITE.erl

@@ -0,0 +1,484 @@
+%% Copyright (c) 2018, Loïc Hoguin <essen@ninenines.eu>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+-module(rfc9220_SUITE).
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-import(ct_helper, [config/2]).
+-import(ct_helper, [doc/1]).
+
+all() -> [{group, enabled}].
+
+groups() ->
+	Tests = ct_helper:all(?MODULE),
+	[{enabled, [], Tests}]. %% @todo Enable parallel when all is better.
+
+init_per_group(Name = enabled, Config) ->
+	cowboy_test:init_http3(Name, #{
+		enable_connect_protocol => true,
+		env => #{dispatch => cowboy_router:compile(init_routes(Config))}
+	}, Config).
+
+end_per_group(Name, _) ->
+	cowboy_test:stop_group(Name).
+
+init_routes(_) -> [
+	{"localhost", [
+		{"/ws", ws_echo, []}
+	]}
+].
+
+% The SETTINGS_ENABLE_CONNECT_PROTOCOL SETTINGS Parameter.
+
+% The new parameter name is SETTINGS_ENABLE_CONNECT_PROTOCOL.  The
+% value of the parameter MUST be 0 or 1.
+
+%    Upon receipt of SETTINGS_ENABLE_CONNECT_PROTOCOL with a value of 1 a
+%    client MAY use the Extended CONNECT definition of this document when
+%    creating new streams.  Receipt of this parameter by a server does not
+%    have any impact.
+%% @todo ignore_client_enable_setting(Config) ->
+
+reject_handshake_when_disabled(Config0) ->
+	doc("Extended CONNECT requests MUST be rejected with a "
+		"H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. "
+		"(RFC9220, RFC8441 4)"),
+	Config = cowboy_test:init_http3(disabled, #{
+		enable_connect_protocol => false,
+		env => #{dispatch => cowboy_router:compile(init_routes(Config0))}
+	}, Config0),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
+	#{
+		conn := Conn,
+		settings := Settings
+	} = rfc9114_SUITE:do_connect(Config),
+	case Settings of
+		#{enable_connect_protocol := false} -> ok;
+		_ when map_size(Settings) =:= 0 -> ok
+	end,
+	%% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+reject_handshake_disabled_by_default(Config0) ->
+	doc("Extended CONNECT requests MUST be rejected with a "
+		"H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. "
+		"(RFC9220, RFC8441 4)"),
+	Config = cowboy_test:init_http3(disabled, #{
+		env => #{dispatch => cowboy_router:compile(init_routes(Config0))}
+	}, Config0),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
+	#{
+		conn := Conn,
+		settings := Settings
+	} = rfc9114_SUITE:do_connect(Config),
+	case Settings of
+		#{enable_connect_protocol := false} -> ok;
+		_ when map_size(Settings) =:= 0 -> ok
+	end,
+	%% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+% The Extended CONNECT Method.
+
+accept_uppercase_pseudo_header_protocol(Config) ->
+	doc("The :protocol pseudo header is case insensitive. (RFC9220, RFC8441 4, RFC9110 7.8)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"WEBSOCKET">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% Receive a 200 response.
+	{ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef),
+	{HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data),
+	<<
+		1, %% HEADERS frame.
+		HLenEnc:2, HLen:HLenBits,
+		EncodedResponse:HLen/bytes
+	>> = Data,
+	{ok, DecodedResponse, _DecData, _DecSt}
+		= cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init()),
+	#{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse),
+	ok.
+
+reject_many_pseudo_header_protocol(Config) ->
+	doc("An extended CONNECT request containing more than one "
+		"protocol component must be rejected with a H3_MESSAGE_ERROR "
+		"stream error. (RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request with more than one :protocol pseudo-header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":protocol">>, <<"mqtt">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+reject_unknown_pseudo_header_protocol(Config) ->
+	doc("An extended CONNECT request containing more than one "
+		"protocol component must be rejected with a 501 Not Implemented "
+		"response. (RFC9220, RFC8441 4)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request with an unknown protocol.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"mqtt">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been rejected with a 501 Not Implemented.
+	#{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef),
+	ok.
+
+reject_invalid_pseudo_header_protocol(Config) ->
+	doc("An extended CONNECT request with an invalid protocol "
+		"component must be rejected with a 501 Not Implemented "
+		"response. (RFC9220, RFC8441 4)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request with an invalid protocol.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket mqtt">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been rejected with a 501 Not Implemented.
+	#{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef),
+	ok.
+
+reject_missing_pseudo_header_scheme(Config) ->
+	doc("An extended CONNECT request whtout a scheme component "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request without a :scheme pseudo-header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+reject_missing_pseudo_header_path(Config) ->
+	doc("An extended CONNECT request whtout a path component "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request without a :path pseudo-header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+% On requests bearing the :protocol pseudo-header, the :authority
+% pseudo-header field is interpreted according to Section 8.1.2.3 of
+% [RFC7540] instead of Section 8.3 of [RFC7540].  In particular the
+% server MUST not make a new TCP connection to the host and port
+% indicated by the :authority.
+
+reject_missing_pseudo_header_authority(Config) ->
+	doc("An extended CONNECT request whtout an authority component "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request without an :authority pseudo-header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+% Using Extended CONNECT To Bootstrap The WebSocket Protocol.
+
+reject_missing_pseudo_header_protocol(Config) ->
+	doc("An extended CONNECT request whtout a protocol component "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request without a :protocol pseudo-header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+% The scheme of the Target URI [RFC7230] MUST be https for wss schemed
+% WebSockets. HTTP/3 does not provide support for ws schemed WebSockets.
+% The websocket URI is still used for proxy autoconfiguration.
+
+reject_connection_header(Config) ->
+	doc("An extended CONNECT request with a connection header "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request with a connection header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"connection">>, <<"upgrade">>},
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+reject_upgrade_header(Config) ->
+	doc("An extended CONNECT request with a upgrade header "
+		"must be rejected with a H3_MESSAGE_ERROR stream error. "
+		"(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send an extended CONNECT request with a upgrade header.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"upgrade">>, <<"websocket">>},
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% The stream should have been aborted.
+	#{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef),
+	ok.
+
+%    After successfully processing the opening handshake the peers should
+%    proceed with The WebSocket Protocol [RFC6455] using the HTTP/2 stream
+%    from the CONNECT transaction as if it were the TCP connection
+%    referred to in [RFC6455].  The state of the WebSocket connection at
+%    this point is OPEN as defined by [RFC6455], Section 4.1.
+%% @todo I'm guessing we should test for things like RST_STREAM,
+%% closing the connection and others?
+
+% Examples.
+
+accept_handshake_when_enabled(Config) ->
+	doc("Confirm the example for Websocket over HTTP/2 works. (RFC9220, RFC8441 5.1)"),
+	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1.
+	#{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config),
+	#{enable_connect_protocol := true} = Settings,
+	%% Send a CONNECT :protocol request to upgrade the stream to Websocket.
+	{ok, StreamRef} = quicer:start_stream(Conn, #{}),
+	{ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([
+		{<<":method">>, <<"CONNECT">>},
+		{<<":protocol">>, <<"websocket">>},
+		{<<":scheme">>, <<"https">>},
+		{<<":path">>, <<"/ws">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<"sec-websocket-version">>, <<"13">>},
+		{<<"origin">>, <<"http://localhost">>}
+	], 0, cow_qpack:init()),
+	{ok, _} = quicer:send(StreamRef, [
+		<<1>>, %% HEADERS frame.
+		cow_http3:encode_int(iolist_size(EncodedRequest)),
+		EncodedRequest
+	]),
+	%% Receive a 200 response.
+	{ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef),
+	{HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data),
+	<<
+		1, %% HEADERS frame.
+		HLenEnc:2, HLen:HLenBits,
+		EncodedResponse:HLen/bytes
+	>> = Data,
+	{ok, DecodedResponse, _DecData, _DecSt}
+		= cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init()),
+	#{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse),
+	%% Masked text hello echoed back clear by the server.
+	Mask = 16#37fa213d,
+	MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>),
+	{ok, _} = quicer:send(StreamRef, cow_http3:data(
+		<<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>)),
+	{ok, WsData} = rfc9114_SUITE:do_receive_data(StreamRef),
+	<<
+		0, %% DATA frame.
+		0:2, 7:6, %% Length (2 bytes header + "Hello").
+		1:1, 0:3, 1:4, 0:1, 5:7, "Hello" %% Websocket frame.
+	>> = WsData,
+	ok.
+
+%% Closing a Websocket stream.
+
+%    The HTTP/3 stream closure is also analogous to the TCP connection
+%    closure of [RFC6455]. Orderly TCP-level closures are represented
+%    as a FIN bit on the stream (Section 4.4 of [HTTP/3]). RST exceptions
+%    are represented with a stream error (Section 8 of [HTTP/3]) of type
+%    H3_REQUEST_CANCELLED (Section 8.1 of [HTTP/3]).
+
+%% @todo client close frame with FIN
+%% @todo server close frame with FIN
+%% @todo client other frame with FIN
+%% @todo server other frame with FIN
+%% @todo client close connection