diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7279af081..9c86fc4ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,8 +21,8 @@ jobs: if: ${{ !cancelled() }} uses: ninenines/ci.erlang.mk/.github/workflows/ci.yaml@master - dialyzer-no-quicer: - name: Check / Dialyzer (without COWBOY_QUICER) + examples: + name: Check examples runs-on: ubuntu-latest steps: @@ -34,30 +34,86 @@ jobs: with: otp-version: '> 0' - - name: Run Dialyzer (without COWBOY_QUICER) - run: make dialyze COWBOY_QUICER=0 + - name: Run ct-examples + run: make ct-examples - examples: - name: Check examples + - name: Upload logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: Common Test logs (examples) + path: | + logs/ + !logs/**/log_private + + http3-erlang-quic: + name: Check HTTP/3 with erlang_quic runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install latest Erlang/OTP + - name: Install Erlang/OTP 27 uses: erlef/setup-beam@v1 with: - otp-version: '> 0' + otp-version: '27' - - name: Run ct-examples - run: make ct-examples + - name: Build + run: make + + - name: Run erlang_quic HTTP/3 tests + run: make ct-h3 ct-rfc9114_quic ct-rfc9220_quic ct-webtransport_quic - name: Upload logs uses: actions/upload-artifact@v4 if: always() with: - name: Common Test logs (examples) + name: Common Test logs (erlang_quic) + path: | + logs/ + !logs/**/log_private + + http3-quicer: + name: Check HTTP/3 with quicer + runs-on: ubuntu-latest + # Allow failure - quicer adapter needs updates for latest emqx/quic API + continue-on-error: true + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Erlang/OTP 27 + uses: erlef/setup-beam@v1 + with: + otp-version: '27' + + - name: Install MsQuic dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake build-essential + + - name: Configure git for GitHub + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global url."https://github.com/".insteadOf git://github.com/ + git config --global url."https://github.com/".insteadOf git@github.com: + git config --global credential.helper store + echo "https://x-access-token:${GITHUB_TOKEN}@github.com" > ~/.git-credentials + + - name: Build with quicer + run: make COWBOY_QUICER=1 + + - name: Run quicer HTTP/3 tests + run: make COWBOY_QUICER=1 ct-rfc9114 ct-rfc9204 ct-rfc9220 + + - name: Upload logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: Common Test logs (quicer) path: | logs/ !logs/**/log_private diff --git a/Makefile b/Makefile index 00ab321e5..8f4abccb8 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,16 @@ DEPS = cowlib ranch dep_cowlib = git https://github.com/ninenines/cowlib 2.16.0 dep_ranch = git https://github.com/ninenines/ranch 1.8.1 +# Conditional QUIC backend. +# Use COWBOY_QUICER=1 for quicer (emqx/quic NIF), default is erlang_quic. ifeq ($(COWBOY_QUICER),1) DEPS += quicer dep_quicer = git https://github.com/emqx/quic main +ERLC_OPTS += -D COWBOY_QUICER=1 +TEST_ERLC_OPTS += -D COWBOY_QUICER=1 +else +DEPS += quic +dep_quic = git https://github.com/benoitc/erlang_quic 0.10.2 endif DOC_DEPS = asciideck @@ -35,8 +42,8 @@ dep_gun = git https://github.com/ninenines/gun master dep_ci.erlang.mk = git https://github.com/ninenines/ci.erlang.mk master DEP_EARLY_PLUGINS = ci.erlang.mk -AUTO_CI_OTP ?= OTP-LATEST-24+ -AUTO_CI_WINDOWS ?= OTP-LATEST-24+ +AUTO_CI_OTP ?= OTP-LATEST-26+ +AUTO_CI_WINDOWS ?= OTP-LATEST-26+ # Hex configuration. @@ -76,11 +83,6 @@ endif ERLC_OPTS += +warn_missing_spec +warn_untyped_record # +bin_opt_info TEST_ERLC_OPTS += +'{parse_transform, eunit_autoexport}' -ifeq ($(COWBOY_QUICER),1) -ERLC_OPTS += -D COWBOY_QUICER=1 -TEST_ERLC_OPTS += -D COWBOY_QUICER=1 -endif - # Generate rebar.config on build. app:: rebar.config diff --git a/ebin/cowboy.app b/ebin/cowboy.app index 002727ef6..03da72b51 100644 --- a/ebin/cowboy.app +++ b/ebin/cowboy.app @@ -1,10 +1,10 @@ {application, 'cowboy', [ {description, "Small, fast, modern HTTP server."}, {vsn, "2.14.2"}, - {modules, ['cowboy','cowboy_app','cowboy_bstr','cowboy_children','cowboy_clear','cowboy_clock','cowboy_compress_h','cowboy_constraints','cowboy_decompress_h','cowboy_handler','cowboy_http','cowboy_http2','cowboy_http3','cowboy_loop','cowboy_metrics_h','cowboy_middleware','cowboy_quicer','cowboy_req','cowboy_rest','cowboy_router','cowboy_static','cowboy_stream','cowboy_stream_h','cowboy_sub_protocol','cowboy_sup','cowboy_tls','cowboy_tracer_h','cowboy_websocket','cowboy_webtransport']}, + {modules, ['cowboy','cowboy_app','cowboy_bstr','cowboy_children','cowboy_clear','cowboy_clock','cowboy_compress_h','cowboy_constraints','cowboy_decompress_h','cowboy_handler','cowboy_http','cowboy_http2','cowboy_http3','cowboy_loop','cowboy_metrics_h','cowboy_middleware','cowboy_quic','cowboy_req','cowboy_rest','cowboy_router','cowboy_static','cowboy_stream','cowboy_stream_h','cowboy_sub_protocol','cowboy_sup','cowboy_tls','cowboy_tracer_h','cowboy_websocket','cowboy_webtransport']}, {registered, [cowboy_sup,cowboy_clock]}, - {applications, [kernel,stdlib,crypto,cowlib,ranch]}, + {applications, [kernel,stdlib,crypto,cowlib,ranch,quic]}, {optional_applications, []}, - {mod, {cowboy_app, []}}, + {mod, {'cowboy_app', []}}, {env, []} ]}. \ No newline at end of file diff --git a/rebar.config b/rebar.config index 22040c91f..d4b6829ea 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,4 @@ {deps, [ -{cowlib,".*",{git,"https://github.com/ninenines/cowlib",{tag,"2.16.0"}}},{ranch,".*",{git,"https://github.com/ninenines/ranch",{tag,"1.8.1"}}} +{cowlib,".*",{git,"https://github.com/ninenines/cowlib",{tag,"2.16.0"}}},{ranch,".*",{git,"https://github.com/ninenines/ranch",{tag,"1.8.1"}}},{quic,".*",{git,"https://github.com/benoitc/erlang_quic",{branch,"main"}}} ]}. {erl_opts, [debug_info,warn_export_vars,warn_shadow_vars,warn_obsolete_guard,warn_missing_spec,warn_untyped_record]}. diff --git a/src/cowboy.erl b/src/cowboy.erl index f148a391c..478dcc55f 100644 --- a/src/cowboy.erl +++ b/src/cowboy.erl @@ -21,9 +21,9 @@ -export([get_env/3]). -export([set_env/3]). --ifdef(COWBOY_QUICER). -export([start_quic/3]). +-ifdef(COWBOY_QUICER). %% Don't warn about the bad quicer specs. -dialyzer([{nowarn_function, start_quic/3}]). -endif. @@ -76,8 +76,6 @@ start_tls(Ref, TransOpts0, ProtoOpts0) -> }, ranch:start_listener(Ref, ranch_ssl, TransOpts, cowboy_tls, ProtoOpts). --ifdef(COWBOY_QUICER). - %% @todo Experimental function to start a barebone QUIC listener. %% This will need to be reworked to be closer to Ranch %% listeners and provide equivalent features. @@ -87,6 +85,8 @@ start_tls(Ref, TransOpts0, ProtoOpts0) -> -spec start_quic(ranch:ref(), #{socket_opts => [{atom(), _}]}, cowboy_http3:opts()) -> {ok, pid()}. +-ifdef(COWBOY_QUICER). +%% quicer (emqx/quicer NIF) implementation. %% @todo Implement dynamic_buffer for HTTP/3 if/when it applies. start_quic(Ref, TransOpts, ProtoOpts) -> {ok, _} = application:ensure_all_started(quicer), @@ -156,6 +156,88 @@ port_0() -> end, Port. +-else. +%% erlang_quic (pure Erlang) implementation. +start_quic(Ref, TransOpts, ProtoOpts) -> + {ok, _} = application:ensure_all_started(quic), + Parent = self(), + SocketOpts0 = maps:get(socket_opts, TransOpts, []), + {Port, SocketOpts2} = case lists:keytake(port, 1, SocketOpts0) of + {value, {port, Port0}, SocketOpts1} -> + {Port0, SocketOpts1}; + false -> + {0, SocketOpts0} + end, + %% Extract certificate configuration. + {Cert, CertChain, Key, SocketOpts} = extract_cert_opts(SocketOpts2), + %% Build server options. + ServerOpts = maps:merge(maps:from_list(SocketOpts), #{ + cert => Cert, + cert_chain => CertChain, + key => Key, + alpn => [<<"h3">>], + connection_handler => fun(_ConnPid, ConnRef) -> + Pid = spawn(fun() -> + receive go -> ok end, + %% Wait for handshake to complete. + receive + {quic, ConnRef, {connected, _Info}} -> + ok + after 30000 -> + exit({shutdown, handshake_timeout}) + end, + process_flag(trap_exit, true), + try cowboy_http3:init(Parent, Ref, ConnRef, ProtoOpts) + catch + exit:{shutdown,_} -> ok; + C:E:S -> + log(error, "CRASH ~p:~p:~p", [C,E,S], ProtoOpts) + end + end), + %% Ownership will be transferred by the listener after this returns. + Pid ! go, + {ok, Pid} + end + }), + quic:start_server(Ref, Port, ServerOpts). + +%% Extract certificate options from socket opts. +extract_cert_opts(SocketOpts) -> + {Certfile, SocketOpts1} = case lists:keytake(certfile, 1, SocketOpts) of + {value, {certfile, CF}, SO1} -> {CF, SO1}; + false -> {undefined, SocketOpts} + end, + {Keyfile, SocketOpts2} = case lists:keytake(keyfile, 1, SocketOpts1) of + {value, {keyfile, KF}, SO2} -> {KF, SO2}; + false -> {undefined, SocketOpts1} + end, + %% Read certificate and key from files. + {Cert, CertChain} = case Certfile of + undefined -> {undefined, []}; + _ -> read_cert_file(Certfile) + end, + Key = case Keyfile of + undefined -> undefined; + _ -> read_key_file(Keyfile) + end, + {Cert, CertChain, Key, SocketOpts2}. + +%% Read certificate file (PEM) and convert to DER. +read_cert_file(Filename) -> + {ok, PemBin} = file:read_file(Filename), + PemEntries = public_key:pem_decode(PemBin), + Certs = [Der || {'Certificate', Der, not_encrypted} <- PemEntries], + case Certs of + [Cert|Chain] -> {Cert, Chain}; + [] -> {undefined, []} + end. + +%% Read key file (PEM) and return the key. +read_key_file(Filename) -> + {ok, PemBin} = file:read_file(Filename), + [PemEntry|_] = public_key:pem_decode(PemBin), + public_key:pem_entry_decode(PemEntry). + -endif. ensure_alpn(TransOpts) -> diff --git a/src/cowboy_http3.erl b/src/cowboy_http3.erl index 4f407cd9c..a01df0f54 100644 --- a/src/cowboy_http3.erl +++ b/src/cowboy_http3.erl @@ -19,7 +19,7 @@ -module(cowboy_http3). --ifdef(COWBOY_QUICER). +-include("cowboy_quic_adapter.hrl"). -export([init/4]). @@ -83,7 +83,7 @@ -record(state, { parent :: pid(), ref :: ranch:ref(), - conn :: cowboy_quicer:quicer_connection_handle(), + conn :: ?QUIC_ADAPTER:connection_handle(), opts = #{} :: opts(), %% Remote address and port for the connection. @@ -95,6 +95,9 @@ %% Client certificate. cert :: undefined | binary(), + %% HTTP/3 connection status for graceful shutdown. + http3_status = connected :: connected | closing, + %% HTTP/3 state machine. http3_machine :: cow_http3_machine:http3_machine(), @@ -114,10 +117,14 @@ %% Streams can spawn zero or more children which are then managed %% by this module if operating as a supervisor. - children = cowboy_children:init() :: cowboy_children:children() + children = cowboy_children:init() :: cowboy_children:children(), + + %% Stream reset rate limiting (CVE-2019-9514 protection). + reset_rate_num = undefined :: undefined | pos_integer(), + reset_rate_time = undefined :: undefined | integer() }). --spec init(pid(), ranch:ref(), cowboy_quicer:quicer_connection_handle(), opts()) +-spec init(pid(), ranch:ref(), ?QUIC_ADAPTER:connection_handle(), opts()) -> no_return(). init(Parent, Ref, Conn, Opts) -> @@ -126,23 +133,23 @@ init(Parent, Ref, Conn, Opts) -> %% @todo An endpoint MAY avoid creating an encoder stream if it will not be used (for example, if its encoder does not wish to use the dynamic table or if the maximum size of the dynamic table permitted by the peer is zero). %% @todo An endpoint MAY avoid creating a decoder stream if its decoder sets the maximum capacity of the dynamic table to zero. {ok, ControlID} = maybe_socket_error(undefined, - cowboy_quicer:start_unidi_stream(Conn, [<<0>>, SettingsBin]), + ?QUIC_ADAPTER:start_unidi_stream(Conn, [<<0>>, SettingsBin]), 'A socket error occurred when opening the control stream.'), {ok, EncoderID} = maybe_socket_error(undefined, - cowboy_quicer:start_unidi_stream(Conn, <<2>>), + ?QUIC_ADAPTER:start_unidi_stream(Conn, <<2>>), 'A socket error occurred when opening the encoder stream.'), {ok, DecoderID} = maybe_socket_error(undefined, - cowboy_quicer:start_unidi_stream(Conn, <<3>>), + ?QUIC_ADAPTER:start_unidi_stream(Conn, <<3>>), 'A socket error occurred when opening the encoder stream.'), %% Set the control, encoder and decoder streams in the machine. HTTP3Machine = cow_http3_machine:init_unidi_local_streams( ControlID, EncoderID, DecoderID, HTTP3Machine0), %% Get the peername/sockname/cert. - {ok, Peer} = maybe_socket_error(undefined, cowboy_quicer:peername(Conn), + {ok, Peer} = maybe_socket_error(undefined, ?QUIC_ADAPTER:peername(Conn), 'A socket error occurred when retrieving the peer name.'), - {ok, Sock} = maybe_socket_error(undefined, cowboy_quicer:sockname(Conn), + {ok, Sock} = maybe_socket_error(undefined, ?QUIC_ADAPTER:sockname(Conn), 'A socket error occurred when retrieving the sock name.'), - CertResult = case cowboy_quicer:peercert(Conn) of + CertResult = case ?QUIC_ADAPTER:peercert(Conn) of {error, no_peercert} -> {ok, undefined}; Cert0 -> @@ -150,11 +157,15 @@ init(Parent, Ref, Conn, Opts) -> end, {ok, Cert} = maybe_socket_error(undefined, CertResult, 'A socket error occurred when retrieving the client TLS certificate.'), - %% Quick! Let's go! - loop(#state{parent=Parent, ref=Ref, conn=Conn, + %% Initialize rate limiting. + CurrentTime = erlang:monotonic_time(millisecond), + State0 = #state{parent=Parent, ref=Ref, conn=Conn, opts=Opts, peer=Peer, sock=Sock, cert=Cert, http3_machine=HTTP3Machine, local_control_id=ControlID, - local_encoder_id=EncoderID, local_decoder_id=DecoderID}). + local_encoder_id=EncoderID, local_decoder_id=DecoderID}, + State = init_reset_rate_limiting(State0, CurrentTime), + %% Quick! Let's go! + loop(State). loop(State0=#state{opts=Opts, children=Children}) -> receive @@ -181,7 +192,7 @@ loop(State0=#state{opts=Opts, children=Children}) -> end. handle_quic_msg(State0=#state{opts=Opts}, Msg) -> - case cowboy_quicer:handle(Msg) of + case ?QUIC_ADAPTER:handle(Msg) of {data, StreamID, IsFin, Data} -> parse(State0, StreamID, Data, IsFin); {datagram, Data} -> @@ -195,6 +206,16 @@ handle_quic_msg(State0=#state{opts=Opts}, Msg) -> {peer_send_shutdown, StreamID} -> State = stream_peer_send_shutdown(State0, StreamID), loop(State); + {goaway, LastStreamID} -> + goaway(State0, {goaway, LastStreamID}); + {transport_error, Code, Reason} -> + Reason1 = {connection_error, {transport_error, Code}, + iolist_to_binary([<<"Transport error: ">>, Reason])}, + terminate(State0, Reason1); + {send_ready, _StreamID} -> + %% Flow control signal - stream is ready to send more data. + %% Currently we send data immediately, so this is informational. + loop(State0); closed -> %% @todo Different error reason if graceful? Reason = {socket_error, closed, 'The socket has been closed.'}, @@ -203,13 +224,10 @@ handle_quic_msg(State0=#state{opts=Opts}, Msg) -> loop(State0); unknown -> cowboy:log(warning, "Received unknown QUIC message ~p.", [Msg], Opts), - loop(State0); - {socket_error, Reason} -> - terminate(State0, {socket_error, Reason, - 'An error has occurred on the socket.'}) + loop(State0) end. -parse(State=#state{opts=Opts}, StreamID, Data, IsFin) -> +parse(State, StreamID, Data, IsFin) -> case stream_get(State, StreamID) of Stream=#stream{buffer= <<>>} -> parse1(State, Stream, Data, IsFin); @@ -221,13 +239,19 @@ parse(State=#state{opts=Opts}, StreamID, Data, IsFin) -> error -> case is_lingering_stream(State, StreamID) of true -> - ok; + loop(State); false -> - %% We avoid logging the data as it could be quite large. - cowboy:log(warning, "Received data for unknown stream ~p.", - [StreamID], Opts) - end, - loop(State) + %% Stream not found. For erlang_quic, streams are created + %% lazily when data arrives (no stream_opened notification). + %% Determine stream type from ID and create the stream. + StreamType = case StreamID band 2 of + 0 -> bidi; + 2 -> unidi + end, + State1 = stream_new_remote(State, StreamID, StreamType), + Stream = stream_get(State1, StreamID), + parse1(State1, Stream, Data, IsFin) + end end. parse1(State, Stream=#stream{status=header}, Data, IsFin) -> @@ -241,7 +265,13 @@ parse1(State=#state{http3_machine=HTTP3Machine0}, {error, Error={connection_error, _, _}, HTTP3Machine} -> terminate(State#state{http3_machine=HTTP3Machine}, Error) end; -%% @todo Handle when IsFin = fin which must terminate the WT session. +%% Handle FIN on WebTransport session CONNECT stream. +%% When the CONNECT stream receives FIN, it's equivalent to a clean close. +parse1(State=#state{conn=Conn}, Stream=#stream{id=SessionID, status= + {webtransport_session, _}}, <<>>, fin) -> + webtransport_event(State, SessionID, {closed, 0, <<>>}), + ?QUIC_ADAPTER:shutdown_stream(Conn, SessionID), + loop(webtransport_terminate_session(State, Stream)); parse1(State=#state{conn=Conn}, Stream=#stream{id=SessionID, status= {webtransport_session, normal}}, Data, IsFin) -> case cow_capsule:parse(Data) of @@ -253,7 +283,7 @@ parse1(State=#state{conn=Conn}, Stream=#stream{id=SessionID, status= %% to the termination of the session process. webtransport_event(State, SessionID, {closed, AppCode, AppMsg}), %% Shutdown the CONNECT stream immediately. - cowboy_quicer:shutdown_stream(Conn, SessionID), + ?QUIC_ADAPTER:shutdown_stream(Conn, SessionID), %% @todo Will we receive a {stream_closed,...} after that? %% If any data is received past that point this is an error. %% @todo Don't crash, error out properly. @@ -271,12 +301,12 @@ parse1(State=#state{conn=Conn}, Stream=#stream{id=SessionID, status= {skip, Len} when Len =< 8192 -> loop(stream_store(State, Stream#stream{ status={webtransport_session, {ignore, Len}}})); - {skip, Len} -> - %% @todo What should be done on capsule error? - error({todo, capsule_too_long, Len}); + {skip, _Len} -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'Capsule payload exceeds maximum allowed size.'}); error -> - %% @todo What should be done on capsule error? - error({todo, capsule_error, Data}) + reset_stream(State, Stream, {stream_error, h3_message_error, + 'Failed to parse capsule on WebTransport session stream.'}) end; parse1(State, Stream=#stream{status= {webtransport_session, {ignore, Len}}}, Data, IsFin) -> @@ -445,9 +475,9 @@ frame(State=#state{http3_machine=HTTP3Machine0}, {ok, {headers, Headers, PseudoHeaders, BodyLen}, Instrs, HTTP3Machine} -> headers_frame(send_instructions(State#state{http3_machine=HTTP3Machine}, Instrs), Stream, IsFin, Headers, PseudoHeaders, BodyLen); - {ok, {trailers, _Trailers}, Instrs, HTTP3Machine} -> - %% @todo Propagate trailers. - send_instructions(State#state{http3_machine=HTTP3Machine}, Instrs); + {ok, {trailers, Trailers}, Instrs, HTTP3Machine} -> + State1 = send_instructions(State#state{http3_machine=HTTP3Machine}, Instrs), + trailers_frame(State1, Stream, Trailers); {ok, GoAway={goaway, _}, HTTP3Machine} -> goaway(State#state{http3_machine=HTTP3Machine}, GoAway); {error, Error={stream_error, _Reason, _Human}, Instrs, HTTP3Machine} -> @@ -473,11 +503,54 @@ data_frame(State=#state{opts=Opts}, 'Unhandled exception in cowboy_stream:data/4.'}) end. -headers_frame(State, Stream, IsFin, Headers, - PseudoHeaders=#{method := <<"CONNECT">>}, _) +trailers_frame(State, Stream=#stream{status={relaying, _, RelayPid}, id=StreamID}, Trailers) -> + RelayPid ! {'$cowboy_relay_trailers', {self(), StreamID}, headers_to_map(Trailers, #{})}, + stream_store(State, Stream); +trailers_frame(State=#state{opts=Opts}, + Stream=#stream{id=StreamID, state=StreamState0}, Trailers) -> + try cowboy_stream:info(StreamID, {trailers, headers_to_map(Trailers, #{})}, StreamState0) of + {Commands, StreamState} -> + commands(State, Stream#stream{state=StreamState}, Commands) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(info, + [StreamID, {trailers, Trailers}, StreamState0], + Class, Exception, Stacktrace), Opts), + reset_stream(State, Stream, {internal_error, {Class, Exception}, + 'Unhandled exception in cowboy_stream:info/3.'}) + end. + +%% Regular CONNECT method (RFC 9110 Section 9.3.6). +%% Only :method and :authority pseudo-headers are allowed. +headers_frame(State=#state{ref=Ref, peer=Peer, sock=Sock, cert=Cert}, + Stream=#stream{id=StreamID}, IsFin, Headers, + PseudoHeaders=#{method := <<"CONNECT">>, authority := Authority}, _) when map_size(PseudoHeaders) =:= 2 -> - early_error(State, Stream, IsFin, Headers, PseudoHeaders, 501, - 'The CONNECT method is currently not implemented. (RFC7231 4.3.6)'); + try cow_http_hd:parse_host(Authority) of + {Host, Port0} -> + Port = case Port0 of undefined -> 443; _ -> Port0 end, + Req = #{ + ref => Ref, + pid => self(), + streamid => StreamID, + peer => Peer, + sock => Sock, + cert => Cert, + method => <<"CONNECT">>, + scheme => <<"https">>, + host => Host, + port => Port, + path => <<>>, + qs => <<>>, + version => 'HTTP/3', + headers => headers_to_map(Headers, #{}), + has_body => IsFin =:= nofin, + body_length => undefined + }, + headers_frame(State, Stream, Req) + catch _:_ -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'Invalid :authority pseudo-header in CONNECT request.'}) + end; headers_frame(State, Stream, IsFin, Headers, PseudoHeaders=#{method := <<"TRACE">>}, _) -> early_error(State, Stream, IsFin, Headers, PseudoHeaders, 501, @@ -611,7 +684,11 @@ parse_datagram(State, Data0) -> webtransport_event(State, SessionID, {datagram, Data}), loop(State); _ -> - error(todo) %% @todo Might be a future WT session or an error. + %% Datagram for unknown/terminated session - silently discard. + %% This can happen for datagrams arriving for sessions that + %% have already been terminated, or for future sessions + %% that haven't been established yet. + loop(State) end. %% Erlang messages. @@ -695,10 +772,10 @@ commands(State0=#state{conn=Conn}, Stream=#stream{id=StreamID}, [{data, IsFin, D {ok, _} = ranch_transport:sendfile(?MODULE, {Conn, StreamID}, Path, Offset, Bytes, []), ok = maybe_socket_error(State0, - cowboy_quicer:send(Conn, StreamID, cow_http3:data(<<>>), IsFin)); + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:data(<<>>), IsFin)); _ -> ok = maybe_socket_error(State0, - cowboy_quicer:send(Conn, StreamID, cow_http3:data(Data), IsFin)) + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:data(Data), IsFin)) end, State = maybe_send_is_fin(State0, Stream, IsFin), commands(State, Stream, Tail); @@ -710,61 +787,27 @@ commands(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, {trailers, HeaderBlock, Instrs, HTTP3Machine} -> State1 = send_instructions(State0#state{http3_machine=HTTP3Machine}, Instrs), ok = maybe_socket_error(State1, - cowboy_quicer:send(Conn, StreamID, cow_http3:headers(HeaderBlock), fin)), + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:headers(HeaderBlock), fin)), State1; {no_trailers, HTTP3Machine} -> ok = maybe_socket_error(State0, - cowboy_quicer:send(Conn, StreamID, cow_http3:data(<<>>), fin)), + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:data(<<>>), fin)), State0#state{http3_machine=HTTP3Machine} end, commands(State, Stream, Tail); -%% Send a push promise. +%% Server push is not implemented. +%% +%% HTTP/2 and HTTP/3 server push has been deprecated and removed by all major +%% browsers: Chrome removed support in v106 (2022), Firefox in v132 (Oct 2024). +%% The feature provided minimal real-world benefit and added complexity. +%% Therefore, push promises are intentionally not supported. %% -%% @todo Responses sent as a result of a push_promise request -%% must not send push_promise frames themselves. +%% Read the request body. %% -%% @todo We should not send push_promise frames when we are -%% in the closing http2_status. -%commands(State0=#state{socket=Socket, transport=Transport, http3_machine=HTTP3Machine0}, -% Stream, [{push, Method, Scheme, Host, Port, Path, Qs, Headers0}|Tail]) -> -% Authority = case {Scheme, Port} of -% {<<"http">>, 80} -> Host; -% {<<"https">>, 443} -> Host; -% _ -> iolist_to_binary([Host, $:, integer_to_binary(Port)]) -% end, -% PathWithQs = iolist_to_binary(case Qs of -% <<>> -> Path; -% _ -> [Path, $?, Qs] -% end), -% PseudoHeaders = #{ -% method => Method, -% scheme => Scheme, -% authority => Authority, -% path => PathWithQs -% }, -% %% We need to make sure the header value is binary before we can -% %% create the Req object, as it expects them to be flat. -% Headers = maps:to_list(maps:map(fun(_, V) -> iolist_to_binary(V) end, Headers0)), -% %% @todo -% State = case cow_http2_machine:prepare_push_promise(StreamID, HTTP3Machine0, -% PseudoHeaders, Headers) of -% {ok, PromisedStreamID, HeaderBlock, HTTP3Machine} -> -% Transport:send(Socket, cow_http2:push_promise( -% StreamID, PromisedStreamID, HeaderBlock)), -% headers_frame(State0#state{http3_machine=HTTP2Machine}, -% PromisedStreamID, fin, Headers, PseudoHeaders, 0); -% {error, no_push} -> -% State0 -% end, -% commands(State, Stream, Tail); -%%% Read the request body. -%commands(State0=#state{flow=Flow, streams=Streams}, Stream, [{flow, Size}|Tail]) -> +%% Unlike HTTP/2, QUIC handles flow control automatically at the transport layer. +%% The QUIC stack sends MAX_STREAM_DATA frames to the peer when it's ready to +%% receive more data. Therefore, the {flow, Size} command is a no-op for HTTP/3. commands(State, Stream, [{flow, _Size}|Tail]) -> - %% @todo We should tell the QUIC stream to increase its window size. -% #{StreamID := Stream=#stream{flow=StreamFlow}} = Streams, -% State = update_window(State0#state{flow=Flow + Size, -% streams=Streams#{StreamID => Stream#stream{flow=StreamFlow + Size}}}, -% StreamID), commands(State, Stream, Tail); %% Supervise a child process. commands(State=#state{children=Children}, Stream=#stream{id=StreamID}, @@ -803,8 +846,8 @@ commands(State0, Stream0=#stream{id=StreamID}, State = info(stream_store(State0, Stream0), StreamID, {headers, 200, Headers}), Stream1 = #stream{status=normal} = stream_get(State, StreamID), #{data_delivery_pid := RelayPid} = ModState, - %% We do not set data_delivery_flow because it is managed by quicer - %% and we do not have an easy way to modify it. + %% We do not set data_delivery_flow because it is managed by the + %% QUIC library and we do not have an easy way to modify it. Stream = Stream1#stream{status={relaying, normal, RelayPid}}, commands(State, Stream, Tail); commands(State0, Stream0=#stream{id=StreamID}, @@ -846,16 +889,16 @@ send_response(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, _ = case Body of {sendfile, Offset, Bytes, Path} -> ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, StreamID, cow_http3:headers(HeaderBlock))), + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:headers(HeaderBlock))), %% Temporary solution to do sendfile over QUIC. {ok, _} = maybe_socket_error(State, ranch_transport:sendfile(?MODULE, {Conn, StreamID}, Path, Offset, Bytes, [])), ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, StreamID, cow_http3:data(<<>>), fin)); + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:data(<<>>), fin)); _ -> ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, StreamID, [ + ?QUIC_ADAPTER:send(Conn, StreamID, [ cow_http3:headers(HeaderBlock), cow_http3:data(Body) ], fin)) @@ -871,11 +914,11 @@ maybe_send_is_fin(State, _, _) -> State. %% Temporary callback to do sendfile over QUIC. --spec send({cowboy_quicer:quicer_connection_handle(), cow_http3:stream_id()}, +-spec send({?QUIC_ADAPTER:connection_handle(), cow_http3:stream_id()}, iodata()) -> ok | {error, any()}. send({Conn, StreamID}, IoData) -> - cowboy_quicer:send(Conn, StreamID, cow_http3:data(IoData)). + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:data(IoData)). send_headers(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, #stream{id=StreamID}, IsFin0, StatusCode, Headers) -> @@ -885,7 +928,7 @@ send_headers(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, headers_to_list(Headers)), State = send_instructions(State0#state{http3_machine=HTTP3Machine}, Instrs), ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, StreamID, cow_http3:headers(HeaderBlock), IsFin)), + ?QUIC_ADAPTER:send(Conn, StreamID, cow_http3:headers(HeaderBlock), IsFin)), State. %% The set-cookie header is special; we can only send one cookie per header. @@ -903,13 +946,13 @@ send_instructions(State, undefined) -> send_instructions(State=#state{conn=Conn, local_decoder_id=DecoderID}, {decoder_instructions, DecData}) -> ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, DecoderID, DecData)), + ?QUIC_ADAPTER:send(Conn, DecoderID, DecData)), State; %% Encoder instructions. send_instructions(State=#state{conn=Conn, local_encoder_id=EncoderID}, {encoder_instructions, EncData}) -> ok = maybe_socket_error(State, - cowboy_quicer:send(Conn, EncoderID, EncData)), + ?QUIC_ADAPTER:send(Conn, EncoderID, EncData)), State. %% Relay data delivery commands. @@ -919,11 +962,11 @@ relay_command(State, StreamID, DataCmd = {data, _, _}) -> commands(State, Stream, [DataCmd]); relay_command(State=#state{conn=Conn}, StreamID, active) -> ok = maybe_socket_error(State, - cowboy_quicer:setopt(Conn, StreamID, active, true)), + ?QUIC_ADAPTER:setopt(Conn, StreamID, active, true)), State; relay_command(State=#state{conn=Conn}, StreamID, passive) -> ok = maybe_socket_error(State, - cowboy_quicer:setopt(Conn, StreamID, active, false)), + ?QUIC_ADAPTER:setopt(Conn, StreamID, active, false)), State. %% We mark the stream as being a WebTransport stream @@ -964,7 +1007,7 @@ webtransport_commands(State, SessionID, Commands) -> wt_commands(State, _, []) -> State; -wt_commands(State0=#state{conn=Conn}, Session=#stream{id=SessionID}, +wt_commands(State0=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, [{open_stream, OpenStreamRef, StreamType, InitialData}|Tail]) -> %% Because opening the stream involves sending a short header %% we necessarily write data. The InitialData variable allows @@ -974,49 +1017,85 @@ wt_commands(State0=#state{conn=Conn}, Session=#stream{id=SessionID}, unidi -> start_unidi_stream end, Header = cow_http3:webtransport_stream_header(SessionID, StreamType), - case cowboy_quicer:StartF(Conn, [Header, InitialData]) of + case ?QUIC_ADAPTER:StartF(Conn, [Header, InitialData]) of {ok, StreamID} -> - %% @todo Pass Session directly? webtransport_event(State0, SessionID, {opened_stream_id, OpenStreamRef, StreamID}), State = stream_new_local(State0, StreamID, StreamType, {webtransport_stream, SessionID}), - wt_commands(State, Session, Tail) - %% @todo Handle errors. - end; -wt_commands(State, Session, [{close_stream, StreamID, Code}|Tail]) -> - %% @todo Check that StreamID belongs to Session. - error({todo, State, Session, [{close_stream, StreamID, Code}|Tail]}); -wt_commands(State=#state{conn=Conn}, Session=#stream{id=SessionID}, - [{send, datagram, Data}|Tail]) -> - case cowboy_quicer:send_datagram(Conn, cow_http3:datagram(SessionID, Data)) of - ok -> - wt_commands(State, Session, Tail) - %% @todo Handle errors. + wt_commands(State, Session, Tail); + {error, Reason} -> + cowboy:log(warning, "Failed to open WebTransport stream: ~p", [Reason], Opts), + webtransport_event(State0, SessionID, + {stream_open_failed, OpenStreamRef, Reason}), + wt_commands(State0, Session, Tail) end; -wt_commands(State=#state{conn=Conn}, Session, [{send, StreamID, Data}|Tail]) -> - %% @todo Check that StreamID belongs to Session. - case cowboy_quicer:send(Conn, StreamID, Data, nofin) of - ok -> - wt_commands(State, Session, Tail) - %% @todo Handle errors. +wt_commands(State0=#state{conn=Conn}, Session=#stream{id=SessionID}, + [{close_stream, StreamID, Code}|Tail]) -> + %% Verify that the stream belongs to this session before closing. + case stream_get(State0, StreamID) of + #stream{status={webtransport_stream, SessionID}} -> + ErrorCode = cow_http3:error_to_code({wt_application_error, Code}), + ?QUIC_ADAPTER:shutdown_stream(Conn, StreamID, both, ErrorCode), + State = stream_closed(State0, StreamID, 0), + wt_commands(State, Session, Tail); + _ -> + %% Stream doesn't exist or doesn't belong to this session. + wt_commands(State0, Session, Tail) end; -wt_commands(State=#state{conn=Conn}, Session, [{send, StreamID, IsFin, Data}|Tail]) -> - %% @todo Check that StreamID belongs to Session. - case cowboy_quicer:send(Conn, StreamID, Data, IsFin) of +wt_commands(State=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, + [{send, datagram, Data}|Tail]) -> + %% Datagrams are unreliable by design, so we just log and continue on error. + case ?QUIC_ADAPTER:send_datagram(Conn, cow_http3:datagram(SessionID, Data)) of ok -> - wt_commands(State, Session, Tail) - %% @todo Handle errors. - end; -wt_commands(State=#state{conn=Conn}, Session=#stream{id=SessionID}, [initiate_close|Tail]) -> + ok; + {error, Reason} -> + cowboy:log(debug, "Failed to send WebTransport datagram: ~p", [Reason], Opts) + end, + wt_commands(State, Session, Tail); +wt_commands(State=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, + [{send, StreamID, Data}|Tail]) -> + %% Verify stream belongs to session before sending. + case stream_get(State, StreamID) of + #stream{status={webtransport_stream, SessionID}} -> + case ?QUIC_ADAPTER:send(Conn, StreamID, Data, nofin) of + ok -> + ok; + {error, Reason} -> + cowboy:log(warning, "Failed to send on WebTransport stream ~p: ~p", + [StreamID, Reason], Opts) + end; + _ -> + cowboy:log(warning, "Attempted to send on unknown WebTransport stream ~p", [StreamID], Opts) + end, + wt_commands(State, Session, Tail); +wt_commands(State=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, + [{send, StreamID, IsFin, Data}|Tail]) -> + %% Verify stream belongs to session before sending. + case stream_get(State, StreamID) of + #stream{status={webtransport_stream, SessionID}} -> + case ?QUIC_ADAPTER:send(Conn, StreamID, Data, IsFin) of + ok -> + ok; + {error, Reason} -> + cowboy:log(warning, "Failed to send on WebTransport stream ~p: ~p", + [StreamID, Reason], Opts) + end; + _ -> + cowboy:log(warning, "Attempted to send on unknown WebTransport stream ~p", [StreamID], Opts) + end, + wt_commands(State, Session, Tail); +wt_commands(State=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, [initiate_close|Tail]) -> %% We must send a WT_DRAIN_SESSION capsule on the CONNECT stream. Capsule = cow_capsule:wt_drain_session(), - case cowboy_quicer:send(Conn, SessionID, Capsule, nofin) of + case ?QUIC_ADAPTER:send(Conn, SessionID, Capsule, nofin) of ok -> - wt_commands(State, Session, Tail) - %% @todo Handle errors. - end; -wt_commands(State0=#state{conn=Conn}, Session=#stream{id=SessionID}, [Cmd|Tail]) + ok; + {error, Reason} -> + cowboy:log(warning, "Failed to send WT_DRAIN_SESSION: ~p", [Reason], Opts) + end, + wt_commands(State, Session, Tail); +wt_commands(State0=#state{conn=Conn, opts=Opts}, Session=#stream{id=SessionID}, [Cmd|Tail]) when Cmd =:= close; element(1, Cmd) =:= close -> %% We must send a WT_CLOSE_SESSION capsule on the CONNECT stream. {AppCode, AppMsg} = case Cmd of @@ -1025,16 +1104,17 @@ wt_commands(State0=#state{conn=Conn}, Session=#stream{id=SessionID}, [Cmd|Tail]) {close, AppCode0, AppMsg0} -> {AppCode0, AppMsg0} end, Capsule = cow_capsule:wt_close_session(AppCode, AppMsg), - case cowboy_quicer:send(Conn, SessionID, Capsule, fin) of + State = case ?QUIC_ADAPTER:send(Conn, SessionID, Capsule, fin) of ok -> - State = webtransport_terminate_session(State0, Session), - %% @todo Because the handler is in a separate process - %% we must wait for it to stop and eventually - %% kill the process if it takes too long. - %% @todo We may need to fully close the CONNECT stream (if remote doesn't reset it). - wt_commands(State, Session, Tail) - %% @todo Handle errors. - end. + webtransport_terminate_session(State0, Session); + {error, Reason} -> + %% Failed to send close capsule, force terminate the session. + cowboy:log(warning, "Failed to send WT_CLOSE_SESSION: ~p, forcing termination", [Reason], Opts), + ?QUIC_ADAPTER:shutdown_stream(Conn, SessionID, both, cow_http3:error_to_code(h3_internal_error)), + webtransport_terminate_session(State0, Session) + end, + %% Continue processing remaining commands even though session is terminated. + wt_commands(State, Session, Tail). webtransport_terminate_session(State=#state{conn=Conn, http3_machine=HTTP3Machine0, streams=Streams0, lingering_streams=Lingering0}, #stream{id=SessionID}) -> @@ -1046,7 +1126,7 @@ webtransport_terminate_session(State=#state{conn=Conn, http3_machine=HTTP3Machin false; (StreamID, #stream{status={webtransport_stream, StreamSessionID}}) when StreamSessionID =:= SessionID -> - cowboy_quicer:shutdown_stream(Conn, StreamID, + ?QUIC_ADAPTER:shutdown_stream(Conn, StreamID, both, cow_http3:error_to_code(wt_session_gone)), false; (_, _) -> @@ -1071,7 +1151,7 @@ stream_peer_send_shutdown(State=#state{conn=Conn}, StreamID) -> Stream = #stream{status={webtransport_session, _}} -> webtransport_event(State, StreamID, {closed, 0, <<>>}), %% Shutdown the CONNECT stream fully. - cowboy_quicer:shutdown_stream(Conn, StreamID), + ?QUIC_ADAPTER:shutdown_stream(Conn, StreamID), webtransport_terminate_session(State, Stream); _ -> State @@ -1085,7 +1165,7 @@ reset_stream(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, end, %% @todo Do we want to close both sides? %% @todo Should we close the send side if the receive side was already closed? - cowboy_quicer:shutdown_stream(Conn, StreamID, + ?QUIC_ADAPTER:shutdown_stream(Conn, StreamID, both, cow_http3:error_to_code(Reason)), State1 = case cow_http3_machine:reset_stream(StreamID, HTTP3Machine0) of {ok, HTTP3Machine} -> @@ -1093,15 +1173,40 @@ reset_stream(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, {error, not_found} -> terminate_stream(State0, Stream, Error) end, -%% @todo -% case reset_rate(State1) of -% {ok, State} -> -% State; -% error -> -% terminate(State1, {connection_error, enhance_your_calm, -% 'Stream reset rate larger than configuration allows. Flood? (CVE-2019-9514)'}) -% end. - State1. + case reset_rate(State1) of + {ok, State} -> + State; + error -> + terminate(State1, {connection_error, h3_excessive_load, + 'Stream reset rate larger than configuration allows. Flood? (CVE-2019-9514)'}) + end. + +%% Stream reset rate limiting (CVE-2019-9514 protection). + +init_reset_rate_limiting(State=#state{opts=Opts}, CurrentTime) -> + {ResetRateNum, ResetRatePeriod} = maps:get(max_reset_stream_rate, Opts, {10, 10000}), + State#state{ + reset_rate_num=ResetRateNum, + reset_rate_time=add_period(CurrentTime, ResetRatePeriod) + }. + +reset_rate(State0=#state{reset_rate_num=Num0, reset_rate_time=Time}) -> + case Num0 - 1 of + 0 -> + CurrentTime = erlang:monotonic_time(millisecond), + if + CurrentTime < Time -> + error; + true -> + %% When the option has a period of infinity we cannot reach this clause. + {ok, init_reset_rate_limiting(State0, CurrentTime)} + end; + Num -> + {ok, State0#state{reset_rate_num=Num}} + end. + +add_period(_, infinity) -> infinity; +add_period(Time, Period) -> Time + Period. stop_stream(State0=#state{http3_machine=HTTP3Machine}, Stream=#stream{id=StreamID}) -> %% We abort reading when stopping the stream but only @@ -1163,15 +1268,39 @@ ignored_frame(State=#state{http3_machine=HTTP3Machine0}, #stream{id=StreamID}) - end. stream_abort_receive(State=#state{conn=Conn}, Stream=#stream{id=StreamID}, Reason) -> - cowboy_quicer:shutdown_stream(Conn, StreamID, + ?QUIC_ADAPTER:shutdown_stream(Conn, StreamID, receiving, cow_http3:error_to_code(Reason)), stream_store(State, Stream#stream{status=stopping}). -%% @todo Graceful connection shutdown. -%% We terminate the connection immediately if it hasn't fully been initialized. +%% Graceful connection shutdown. +%% +%% When we receive a GOAWAY frame from the client, we update our status +%% and begin graceful shutdown. When the server initiates shutdown, +%% we send a GOAWAY frame and begin graceful shutdown. -spec goaway(#state{}, {goaway, _}) -> no_return(). -goaway(State, {goaway, _}) -> - terminate(State, {stop, goaway, 'The connection is going away.'}). +goaway(State, {goaway, _LastStreamID}) -> + %% Client initiated graceful shutdown. + %% We should stop accepting new streams and finish existing ones. + terminate(State#state{http3_status=closing}, {stop, goaway, 'The connection is going away.'}). + +%% Send a GOAWAY frame to initiate graceful shutdown. +send_goaway(#state{conn=Conn, local_control_id=ControlID, streams=Streams}) -> + %% Find the maximum client-initiated bidirectional stream ID. + %% Client-initiated bidi streams have ID rem 4 == 0. + LastStreamID = maps:fold(fun(StreamID, _, Max) -> + case StreamID rem 4 of + 0 when StreamID > Max -> StreamID; + _ -> Max + end + end, 0, Streams), + GoAwayFrame = build_goaway_frame(LastStreamID), + ?QUIC_ADAPTER:send(Conn, ControlID, GoAwayFrame). + +%% Build a GOAWAY frame (frame type 7). +build_goaway_frame(StreamID) -> + EncodedStreamID = cow_http3:encode_int(StreamID), + Len = byte_size(iolist_to_binary([EncodedStreamID])), + [<<7>>, cow_http3:encode_int(Len), EncodedStreamID]. %% Function copied from cowboy_http. maybe_socket_error(State, {error, closed}) -> @@ -1189,29 +1318,27 @@ maybe_socket_error(State, {error, Reason}, Human) -> -spec terminate(#state{} | undefined, _) -> no_return(). terminate(undefined, Reason) -> exit({shutdown, Reason}); -terminate(State=#state{conn=Conn, %http3_status=Status, - %http3_machine=HTTP3Machine, +terminate(State=#state{conn=Conn, http3_status=Status, streams=Streams, children=Children}, Reason) -> -% if -% Status =:= connected; Status =:= closing_initiated -> -%% @todo -% %% We are terminating so it's OK if we can't send the GOAWAY anymore. -% _ = cowboy_quicer:send(Conn, ControlID, cow_http3:goaway( -% cow_http3_machine:get_last_streamid(HTTP3Machine))), - %% We already sent the GOAWAY frame. -% Status =:= closing -> -% ok -% end, + %% Send GOAWAY if we haven't already done so. + %% Wrap in try/catch since the QUIC connection may already be closed. + _ = case Status of + connected -> + %% We are terminating so it's OK if we can't send the GOAWAY anymore. + try send_goaway(State) catch _:_ -> ok end; + closing -> + %% We already sent the GOAWAY frame. + ok + end, terminate_all_streams(State, maps:to_list(Streams), Reason), cowboy_children:terminate(Children), -% terminate_linger(State), - _ = cowboy_quicer:shutdown(Conn, cow_http3:error_to_code(terminate_reason(Reason))), + _ = ?QUIC_ADAPTER:shutdown(Conn, cow_http3:error_to_code(terminate_reason(Reason))), exit({shutdown, Reason}). +terminate_reason({connection_error, {transport_error, _Code}, _}) -> h3_internal_error; terminate_reason({connection_error, Reason, _}) -> Reason; terminate_reason({stop, _, _}) -> h3_no_error; terminate_reason({socket_error, _, _}) -> h3_internal_error. -%terminate_reason({internal_error, _, _}) -> internal_error. terminate_all_streams(_, [], _) -> ok; @@ -1305,5 +1432,3 @@ stream_linger(State=#state{lingering_streams=Lingering0}, StreamID) -> is_lingering_stream(#state{lingering_streams=Lingering}, StreamID) -> lists:member(StreamID, Lingering). - --endif. diff --git a/src/cowboy_quic.erl b/src/cowboy_quic.erl new file mode 100644 index 000000000..da881666d --- /dev/null +++ b/src/cowboy_quic.erl @@ -0,0 +1,331 @@ +%% Copyright (c) Loic Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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. + +%% QUIC transport using the pure Erlang erlang_quic library. + +-module(cowboy_quic). + +%% Connection. +-export([peername/1]). +-export([sockname/1]). +-export([peercert/1]). +-export([shutdown/2]). + +%% Streams. +-export([start_bidi_stream/2]). +-export([start_unidi_stream/2]). +-export([setopt/4]). +-export([send/3]). +-export([send/4]). +-export([send_datagram/2]). +-export([shutdown_stream/2]). +-export([shutdown_stream/4]). + +%% Messages. +-export([handle/1]). + +-type connection_handle() :: reference(). +-export_type([connection_handle/0]). + +-type app_errno() :: non_neg_integer(). + +%% Connection. + +-spec peername(connection_handle()) + -> {ok, {inet:ip_address(), inet:port_number()}} + | {error, any()}. + +peername(Conn) -> + quic:peername(Conn). + +-spec sockname(connection_handle()) + -> {ok, {inet:ip_address(), inet:port_number()}} + | {error, any()}. + +sockname(Conn) -> + quic:sockname(Conn). + +-spec peercert(connection_handle()) + -> {ok, public_key:der_encoded()} + | {error, any()}. + +peercert(Conn) -> + quic:peercert(Conn). + +-spec shutdown(connection_handle(), app_errno()) + -> ok | {error, any()}. + +shutdown(Conn, ErrorCode) -> + quic:close(Conn, ErrorCode). + +%% Streams. + +-spec start_bidi_stream(connection_handle(), iodata()) + -> {ok, cow_http3:stream_id()} + | {error, any()}. + +start_bidi_stream(Conn, InitialData) -> + case quic:open_stream(Conn) of + {ok, StreamID} -> + case quic:send_data(Conn, StreamID, InitialData, false) of + ok -> + {ok, StreamID}; + Error -> + Error + end; + Error -> + Error + end. + +-spec start_unidi_stream(connection_handle(), iodata()) + -> {ok, cow_http3:stream_id()} + | {error, any()}. + +start_unidi_stream(Conn, InitialData) -> + case quic:open_unidirectional_stream(Conn) of + {ok, StreamID} -> + case quic:send_data(Conn, StreamID, InitialData, false) of + ok -> + {ok, StreamID}; + Error -> + Error + end; + Error -> + Error + end. + +-spec setopt(connection_handle(), cow_http3:stream_id(), active, boolean()) + -> ok | {error, any()}. + +setopt(Conn, _StreamID, active, _Value) -> + %% erlang_quic uses process messages, always active + %% Set connection-level options if needed + quic:setopts(Conn, []). + +-spec send(connection_handle(), cow_http3:stream_id(), iodata()) + -> ok | {error, any()}. + +send(Conn, StreamID, Data) -> + send(Conn, StreamID, Data, nofin). + +-spec send(connection_handle(), cow_http3:stream_id(), iodata(), cow_http:fin()) + -> ok | {error, any()}. + +send(Conn, StreamID, Data, IsFin) -> + Fin = case IsFin of + fin -> true; + nofin -> false + end, + quic:send_data(Conn, StreamID, Data, Fin). + +-spec send_datagram(connection_handle(), iodata()) + -> ok | {error, any()}. + +send_datagram(Conn, Data) -> + quic:send_datagram(Conn, Data). + +-spec shutdown_stream(connection_handle(), cow_http3:stream_id()) + -> ok. + +shutdown_stream(Conn, StreamID) -> + _ = quic:reset_stream(Conn, StreamID, 0), + ok. + +-spec shutdown_stream(connection_handle(), + cow_http3:stream_id(), both | receiving, app_errno()) + -> ok. + +shutdown_stream(Conn, StreamID, _Dir, ErrorCode) -> + _ = quic:reset_stream(Conn, StreamID, ErrorCode), + ok. + +%% Messages. +%% +%% Translate erlang_quic messages to cowboy_quic format. +%% +%% erlang_quic format: +%% {quic, ConnRef, {stream_data, StreamId, Data, Fin}} +%% {quic, ConnRef, {stream_opened, StreamId}} +%% {quic, ConnRef, {stream_reset, StreamId, ErrorCode}} +%% {quic, ConnRef, {closed, Reason}} +%% {quic, ConnRef, {stop_sending, StreamId, ErrorCode}} +%% {quic, ConnRef, {datagram, Data}} +%% +%% cowboy_quic format: +%% {data, StreamID, fin|nofin, Data} +%% {datagram, Data} +%% {stream_started, StreamID, unidi|bidi} +%% {stream_closed, StreamID, ErrorCode} +%% closed +%% {peer_send_shutdown, StreamID} + +-spec handle({quic, reference(), term()}) + -> {data, cow_http3:stream_id(), cow_http:fin(), binary()} + | {datagram, binary()} + | {stream_started, cow_http3:stream_id(), unidi | bidi} + | {stream_closed, cow_http3:stream_id(), app_errno()} + | {goaway, cow_http3:stream_id()} + | {transport_error, non_neg_integer(), binary()} + | {send_ready, cow_http3:stream_id()} + | closed + | {peer_send_shutdown, cow_http3:stream_id()} + | ok + | unknown. + +%% Stream data received. +handle({quic, _ConnRef, {stream_data, StreamID, Data, Fin}}) -> + IsFin = case Fin of + true -> fin; + false -> nofin + end, + {data, StreamID, IsFin, Data}; + +%% Datagram received. +handle({quic, _ConnRef, {datagram, Data}}) -> + {datagram, Data}; + +%% New stream opened by peer. +handle({quic, _ConnRef, {stream_opened, StreamID}}) -> + %% Determine stream type from ID (bit 1: 0=bidi, 1=unidi) + StreamType = case StreamID band 2 of + 0 -> bidi; + 2 -> unidi + end, + {stream_started, StreamID, StreamType}; + +%% Stream reset by peer. +handle({quic, _ConnRef, {stream_reset, StreamID, ErrorCode}}) -> + {stream_closed, StreamID, ErrorCode}; + +%% Connection closed. +handle({quic, _ConnRef, {closed, _Reason}}) -> + closed; + +%% Peer initiated shutdown of sending. +handle({quic, _ConnRef, {stop_sending, StreamID, _ErrorCode}}) -> + {peer_send_shutdown, StreamID}; + +%% Connection established (server receives this after handshake). +%% This is informational; the connection is already set up. +handle({quic, _ConnRef, {connected, _Info}}) -> + ok; + +%% Transport error received from peer or detected locally. +%% Forward to cowboy_http3 for proper connection termination. +handle({quic, _ConnRef, {transport_error, Code, Reason}}) -> + {transport_error, Code, Reason}; + +%% GoAway received from peer - graceful shutdown initiated. +handle({quic, _ConnRef, {goaway, LastStreamID}}) -> + {goaway, LastStreamID}; + +%% Session ticket received for 0-RTT resumption. +%% Currently informational; could be stored for client implementations. +handle({quic, _ConnRef, {session_ticket, _Ticket}}) -> + ok; + +%% Stream ready to send - flow control signal. +%% Forward to allow cowboy_http3 to resume sending on this stream. +handle({quic, _ConnRef, {send_ready, StreamID}}) -> + {send_ready, StreamID}; + +%% Timer notification for internal QUIC timers. +%% Handled internally by erlang_quic. +handle({quic, _ConnRef, {timer, _NextTimeoutMs}}) -> + ok; + +%% Unknown message - let cowboy_http3 decide how to handle. +handle(_Msg) -> + unknown. + +%% EUnit tests for the handle/1 function. +-ifdef(TEST). + +handle_stream_data_test() -> + Conn = make_ref(), + {data, 0, nofin, <<"data">>} = handle({quic, Conn, {stream_data, 0, <<"data">>, false}}). + +handle_stream_data_fin_test() -> + Conn = make_ref(), + {data, 4, fin, <<"data">>} = handle({quic, Conn, {stream_data, 4, <<"data">>, true}}). + +handle_stream_opened_bidi_test() -> + Conn = make_ref(), + %% Stream ID 0 has bit 1 = 0, so it's bidirectional + {stream_started, 0, bidi} = handle({quic, Conn, {stream_opened, 0}}). + +handle_stream_opened_unidi_test() -> + Conn = make_ref(), + %% Stream ID 2 has bit 1 = 1, so it's unidirectional + {stream_started, 2, unidi} = handle({quic, Conn, {stream_opened, 2}}). + +handle_stream_opened_client_initiated_bidi_test() -> + Conn = make_ref(), + %% Stream ID 4 (client-initiated, bidi) has bit 1 = 0 + {stream_started, 4, bidi} = handle({quic, Conn, {stream_opened, 4}}). + +handle_stream_opened_client_initiated_unidi_test() -> + Conn = make_ref(), + %% Stream ID 6 (client-initiated, unidi) has bit 1 = 1 + {stream_started, 6, unidi} = handle({quic, Conn, {stream_opened, 6}}). + +handle_datagram_test() -> + Conn = make_ref(), + {datagram, <<"dgram">>} = handle({quic, Conn, {datagram, <<"dgram">>}}). + +handle_stream_reset_test() -> + Conn = make_ref(), + {stream_closed, 4, 256} = handle({quic, Conn, {stream_reset, 4, 256}}). + +handle_closed_test() -> + Conn = make_ref(), + closed = handle({quic, Conn, {closed, normal}}). + +handle_stop_sending_test() -> + Conn = make_ref(), + {peer_send_shutdown, 8} = handle({quic, Conn, {stop_sending, 8, 0}}). + +handle_connected_test() -> + Conn = make_ref(), + ok = handle({quic, Conn, {connected, #{}}}). + +handle_transport_error_test() -> + Conn = make_ref(), + {transport_error, 1, <<"error">>} = handle({quic, Conn, {transport_error, 1, <<"error">>}}). + +handle_goaway_test() -> + Conn = make_ref(), + {goaway, 100} = handle({quic, Conn, {goaway, 100}}). + +handle_session_ticket_test() -> + Conn = make_ref(), + ok = handle({quic, Conn, {session_ticket, <<"ticket">>}}). + +handle_send_ready_test() -> + Conn = make_ref(), + {send_ready, 4} = handle({quic, Conn, {send_ready, 4}}). + +handle_timer_test() -> + Conn = make_ref(), + ok = handle({quic, Conn, {timer, 1000}}). + +handle_unknown_test() -> + Conn = make_ref(), + unknown = handle({quic, Conn, {unknown_event, data}}). + +handle_completely_unknown_test() -> + unknown = handle({something, completely, different}). + +-endif. %% TEST diff --git a/src/cowboy_quic_adapter.hrl b/src/cowboy_quic_adapter.hrl new file mode 100644 index 000000000..6142e76f5 --- /dev/null +++ b/src/cowboy_quic_adapter.hrl @@ -0,0 +1,27 @@ +%% Copyright (c) Loic Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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. + +%% QUIC adapter selection header. +%% +%% This header selects the appropriate QUIC backend module based on +%% compile-time flags: +%% - COWBOY_QUICER=1: Use emqx/quicer NIF (cowboy_quicer) +%% - Default: Use pure Erlang erlang_quic (cowboy_quic) + +-ifdef(COWBOY_QUICER). +-define(QUIC_ADAPTER, cowboy_quicer). +-else. +-define(QUIC_ADAPTER, cowboy_quic). +-endif. diff --git a/src/cowboy_quicer.erl b/src/cowboy_quicer.erl index 6705e9b31..9d63f1466 100644 --- a/src/cowboy_quicer.erl +++ b/src/cowboy_quicer.erl @@ -41,6 +41,10 @@ -type quicer_connection_handle() :: reference(). -export_type([quicer_connection_handle/0]). +%% Alias for compatibility with cowboy_quic module interface. +-type connection_handle() :: quicer_connection_handle(). +-export_type([connection_handle/0]). + -type quicer_app_errno() :: non_neg_integer(). -include_lib("quicer/include/quicer.hrl"). diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl index 541e8f904..760777e17 100644 --- a/test/cowboy_test.erl +++ b/test/cowboy_test.erl @@ -38,11 +38,9 @@ init_http2(Ref, ProtoOpts, Config) -> [{ref, Ref}, {type, ssl}, {protocol, http2}, {port, Port}, {opts, Opts}|Config]. %% @todo This will probably require TransOpts as argument. +-ifdef(COWBOY_QUICER). +%% quicer version: uses quicer:sockname/1 and persistent_term for listener storage. init_http3(Ref, ProtoOpts, Config) -> - %% @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 - %% we currently have no authentication based security. DataDir = filename:dirname(filename:dirname(config(data_dir, Config))) ++ "/rfc9114_SUITE_data", TransOpts = #{ @@ -53,7 +51,7 @@ init_http3(Ref, ProtoOpts, Config) -> }, {ok, Listener} = cowboy:start_quic(Ref, TransOpts, ProtoOpts), {ok, {_, Port}} = quicer:sockname(Listener), - %% @todo Keep listener information around in a better place. + %% 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]. @@ -64,29 +62,54 @@ stop_group(Ref) -> Listener -> quicer:close_listener(Listener) end. +-else. +%% erlang_quic version: uses quic:get_server_info and quic_listener:get_port. +init_http3(Ref, ProtoOpts, Config) -> + DataDir = filename:dirname(filename:dirname(config(data_dir, Config))) + ++ "/rfc9114_SUITE_data", + TransOpts = #{ + socket_opts => [ + {certfile, DataDir ++ "/server.pem"}, + {keyfile, DataDir ++ "/server.key"} + ] + }, + {ok, _Pid} = cowboy:start_quic(Ref, TransOpts, ProtoOpts), + %% Get the actual port from the listener. + %% Navigate supervisor tree: quic_listener_sup -> quic_listener_sup_sup -> quic_listener + {ok, #{pid := SupPid}} = quic:get_server_info(Ref), + [{quic_listener_sup_sup, ListenerSupSup, supervisor, _}|_] = + [C || C = {Id, _, _, _} <- supervisor:which_children(SupPid), Id =:= quic_listener_sup_sup], + [{_, ListenerPid, worker, _}|_] = supervisor:which_children(ListenerSupSup), + Port = quic_listener:get_port(ListenerPid), + [{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config]. + +stop_group(Ref) -> + %% Try to stop as a ranch listener first (most common case), + %% fall back to QUIC server if not found. + case cowboy:stop_listener(Ref) of + ok -> + ok; + {error, not_found} -> + quic:stop_server(Ref) + end. +-endif. %% Common group of listeners used by most suites. common_all() -> - All = [ + %% Don't run HTTP/3 tests in common groups because Gun requires quicer. + %% HTTP/3 is tested separately in h3_SUITE and rfc9114_quic_SUITE + %% using erlang_quic directly. + [ {group, http}, {group, https}, {group, h2}, {group, h2c}, - {group, h3}, {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_compress}, - {group, h3_compress} - ], - %% Don't run HTTP/3 tests on Windows for now. - case os:type() of - {win32, _} -> - All -- [{group, h3}, {group, h3_compress}]; - _ -> - All - end. + {group, h2c_compress} + ]. common_groups(Tests) -> Parallel = case os:getenv("NO_PARALLEL") of @@ -100,25 +123,18 @@ common_groups(Tests, Parallel) -> parallel -> [parallel]; no_parallel -> [] end, - Groups = [ + %% Don't include HTTP/3 groups because Gun requires quicer. + %% HTTP/3 is tested separately in h3_SUITE and rfc9114_quic_SUITE. + [ {http, Opts, Tests}, {https, Opts, Tests}, {h2, Opts, Tests}, {h2c, Opts, Tests}, - {h3, Opts, Tests}, {http_compress, Opts, Tests}, {https_compress, Opts, Tests}, {h2_compress, Opts, Tests}, - {h2c_compress, Opts, Tests}, - {h3_compress, Opts, Tests} - ], - %% Don't run HTTP/3 tests on Windows for now. - case os:type() of - {win32, _} -> - Groups -- [{h3, Opts, Tests}, {h3_compress, Opts, Tests}]; - _ -> - Groups - end. + {h2c_compress, Opts, Tests} + ]. init_common_groups(Name, Config, Mod) -> init_common_groups(Name, Config, Mod, #{}). @@ -179,7 +195,7 @@ gun_open(Config) -> gun_open(Config, Opts) -> TlsOpts = case proplists:get_value(no_cert, Config, false) of true -> [{verify, verify_none}]; - false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer. + false -> ct_helper:get_certs_from_ets() end, {ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{ retry => 0, diff --git a/test/draft_h3_webtransport_SUITE.erl b/test/draft_h3_webtransport_SUITE.erl index 05a6c1704..bc348241e 100644 --- a/test/draft_h3_webtransport_SUITE.erl +++ b/test/draft_h3_webtransport_SUITE.erl @@ -811,4 +811,10 @@ do_receive_datagram(Conn) -> error({timeout, waiting_for_datagram}) end. +-else. + +all() -> []. + +groups() -> []. + -endif. diff --git a/test/h3_SUITE.erl b/test/h3_SUITE.erl new file mode 100644 index 000000000..f1b2259f9 --- /dev/null +++ b/test/h3_SUITE.erl @@ -0,0 +1,201 @@ +%% Copyright (c) Loïc Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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. + +%% HTTP/3 test suite using erlang_quic client. + +-module(h3_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +all() -> + [{group, h3}]. + +groups() -> + [{h3, [], [ + connect_h3, + request_response, + request_with_body, + multiple_requests + ]}]. + +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/", hello_h, []}, + {"/echo/:key", echo_h, []} + ]} +]. + +%% Tests. + +connect_h3(Config) -> + doc("Establish an HTTP/3 connection."), + Port = config(port, Config), + Opts = #{ + alpn => [<<"h3">>], + verify => false + }, + {ok, Conn} = quic:connect("localhost", Port, Opts, self()), + receive + {quic, Conn, {connected, _Info}} -> + ok + after 5000 -> + error(timeout) + end, + quic:close(Conn, 0), + ok. + +request_response(Config) -> + doc("Send a simple GET request and receive response."), + Port = config(port, Config), + Opts = #{ + alpn => [<<"h3">>], + verify => false + }, + {ok, Conn} = quic:connect("localhost", Port, Opts, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + %% Open control, encoder, decoder streams (required for HTTP/3). + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + %% Open request stream and send GET request. + {ok, StreamID} = quic:open_stream(Conn), + {ok, HeaderBlock, _EncData, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), true), + %% Wait for response. + _Response = receive_response(Conn, StreamID, <<>>, 5000), + quic:close(Conn, 0), + ok. + +request_with_body(Config) -> + doc("Send a POST request with body."), + Port = config(port, Config), + Opts = #{ + alpn => [<<"h3">>], + verify => false + }, + {ok, Conn} = quic:connect("localhost", Port, Opts, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + %% Open control, encoder, decoder streams. + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + %% Open request stream and send POST request. + {ok, StreamID} = quic:open_stream(Conn), + Body = <<"Hello, HTTP/3!">>, + {ok, HeaderBlock, _EncData, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/body">>}, + {<<"content-length">>, integer_to_binary(byte_size(Body))} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), false), + ok = quic:send_data(Conn, StreamID, cow_http3:data(Body), true), + %% Wait for response. + _Response = receive_response(Conn, StreamID, <<>>, 5000), + quic:close(Conn, 0), + ok. + +multiple_requests(Config) -> + doc("Send multiple requests on the same connection."), + Port = config(port, Config), + Opts = #{ + alpn => [<<"h3">>], + verify => false + }, + {ok, Conn} = quic:connect("localhost", Port, Opts, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + %% Open control, encoder, decoder streams. + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + %% Send first request. + {ok, StreamID1} = quic:open_stream(Conn), + {ok, HeaderBlock1, _, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID1, cow_http3:headers(HeaderBlock1), true), + %% Send second request before waiting for first response. + {ok, StreamID2} = quic:open_stream(Conn), + {ok, HeaderBlock2, _, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID2, cow_http3:headers(HeaderBlock2), true), + %% Wait for both responses. + _Response1 = receive_response(Conn, StreamID1, <<>>, 5000), + _Response2 = receive_response(Conn, StreamID2, <<>>, 5000), + quic:close(Conn, 0), + ok. + +%% Helpers. + +receive_response(Conn, StreamID, Acc, Timeout) -> + receive + {quic, Conn, {stream_data, StreamID, Data, true}} -> + <>; + {quic, Conn, {stream_data, StreamID, Data, false}} -> + receive_response(Conn, StreamID, <>, Timeout); + {quic, Conn, _Msg} -> + %% Ignore other messages (stream_opened, etc.) + receive_response(Conn, StreamID, Acc, Timeout) + after Timeout -> + {partial, Acc} + end. diff --git a/test/rfc9114_quic_SUITE.erl b/test/rfc9114_quic_SUITE.erl new file mode 100644 index 000000000..ecbf1e665 --- /dev/null +++ b/test/rfc9114_quic_SUITE.erl @@ -0,0 +1,284 @@ +%% Copyright (c) Loic Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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(rfc9114_quic_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +all() -> + [{group, h3}]. + +groups() -> + [{h3, [], ct_helper:all(?MODULE)}]. + +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/", hello_h, []}, + {"/echo/:key", echo_h, []} + ]} +]. + +alpn(Config) -> + doc("Successful ALPN negotiation. (RFC9114 3.1)"), + {ok, Conn} = quic:connect("localhost", config(port, Config), + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> + ok + after 5000 -> + error(timeout) + end, + quic:close(Conn, 0), + ok. + +req_stream(Config) -> + doc("Complete lifecycle of a request stream. (RFC9114 4.1)"), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + {ok, StreamID} = quic:open_stream(Conn), + {ok, HeaderBlock, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), true), + _Response = do_receive_data(Conn, StreamID), + quic:close(Conn, 0), + ok. + +connection_establishment(Config) -> + doc("Verify connection can be established and closed cleanly."), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + timer:sleep(100), + quic:close(Conn, 0), + ok. + +multiple_requests(Config) -> + doc("Send multiple requests on separate streams."), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + {ok, StreamID1} = quic:open_stream(Conn), + {ok, HeaderBlock1, _, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID1, cow_http3:headers(HeaderBlock1), true), + {ok, StreamID2} = quic:open_stream(Conn), + {ok, HeaderBlock2, _, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID2, cow_http3:headers(HeaderBlock2), true), + _Response1 = do_receive_data(Conn, StreamID1), + _Response2 = do_receive_data(Conn, StreamID2), + quic:close(Conn, 0), + ok. + +post_with_body(Config) -> + doc("POST request with body that gets echoed back. (RFC9114 4.1)"), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + {ok, StreamID} = quic:open_stream(Conn), + Body = <<"Hello HTTP/3 World!">>, + {ok, HeaderBlock, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, integer_to_binary(byte_size(Body))} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), false), + ok = quic:send_data(Conn, StreamID, cow_http3:data(Body), true), + _Response = do_receive_data(Conn, StreamID), + quic:close(Conn, 0), + ok. + +headers_then_trailers(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS must be accepted. (RFC9114 4.1)"), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + {ok, StreamID} = quic:open_stream(Conn), + EncSt0 = cow_qpack:init(encoder), + {ok, HeaderBlock, _, EncSt1} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, EncSt0), + {ok, TrailerBlock, _, _} = cow_qpack:encode_field_section([ + {<<"x-trailer">>, <<"value">>} + ], 0, EncSt1), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), false), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(TrailerBlock), true), + _Response = do_receive_data(Conn, StreamID), + quic:close(Conn, 0), + ok. + +large_body(Config) -> + doc("Send a request with a moderately large body (8KB)."), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + {ok, StreamID} = quic:open_stream(Conn), + Body = binary:copy(<<"x">>, 8192), + {ok, HeaderBlock, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, integer_to_binary(byte_size(Body))} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), false), + ok = quic:send_data(Conn, StreamID, cow_http3:data(Body), true), + _Response = do_receive_data(Conn, StreamID, <<>>, 15000), + quic:close(Conn, 0), + ok. + +concurrent_streams(Config) -> + doc("Send many concurrent requests to test stream handling."), + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{}), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + StreamIDs = lists:map(fun(_) -> + {ok, StreamID} = quic:open_stream(Conn), + {ok, HeaderBlock, _, _} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, cow_http3:headers(HeaderBlock), true), + StreamID + end, lists:seq(1, 10)), + lists:foreach(fun(StreamID) -> + _Response = do_receive_data(Conn, StreamID) + end, StreamIDs), + quic:close(Conn, 0), + ok. + +do_receive_data(Conn, StreamID) -> + do_receive_data(Conn, StreamID, <<>>, 10000). + +do_receive_data(Conn, StreamID, Acc, Timeout) -> + receive + {quic, Conn, {stream_data, StreamID, Data, true}} -> + <>; + {quic, Conn, {stream_data, StreamID, Data, false}} -> + do_receive_data(Conn, StreamID, <>, Timeout); + {quic, Conn, _Msg} -> + do_receive_data(Conn, StreamID, Acc, Timeout) + after Timeout -> + Acc + end. diff --git a/test/rfc9220_quic_SUITE.erl b/test/rfc9220_quic_SUITE.erl new file mode 100644 index 000000000..cf6cdf450 --- /dev/null +++ b/test/rfc9220_quic_SUITE.erl @@ -0,0 +1,247 @@ +%% Copyright (c) Loic Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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_quic_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}]. + +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, []} + ]} +]. + +do_connect(Config) -> + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{ + enable_connect_protocol => true + }), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + timer:sleep(100), + Settings = receive_server_settings(Conn, #{}), + #{conn => Conn, settings => Settings}. + +receive_server_settings(Conn, Acc) -> + receive + {quic, Conn, {stream_opened, _StreamID, unidirectional}} -> + receive_server_settings(Conn, Acc); + {quic, Conn, {stream_data, _StreamID, Data, _Fin}} -> + parse_settings(Data, Acc); + {quic, Conn, _Other} -> + receive_server_settings(Conn, Acc) + after 500 -> + Acc + end. + +parse_settings(<<0, Rest/binary>>, Acc) -> + parse_settings_frame(Rest, Acc); +parse_settings(<<_, _/binary>>, Acc) -> + Acc; +parse_settings(<<>>, Acc) -> + Acc. + +parse_settings_frame(<<4, Rest/binary>>, Acc) -> + {Len, Rest2} = cow_http3:parse_int(Rest), + <> = Rest2, + decode_settings(SettingsPayload, Acc); +parse_settings_frame(_, Acc) -> + Acc. + +decode_settings(<<>>, Acc) -> + Acc; +decode_settings(Bin, Acc) -> + {Id, Rest} = cow_http3:parse_int(Bin), + {Value, Rest2} = cow_http3:parse_int(Rest), + Acc2 = case Id of + 8 -> Acc#{enable_connect_protocol => Value =:= 1}; + _ -> Acc + end, + decode_settings(Rest2, Acc2). + +do_receive_data(Conn, StreamID) -> + do_receive_data(Conn, StreamID, <<>>, 5000). + +do_receive_data(Conn, StreamID, Acc, Timeout) -> + receive + {quic, Conn, {stream_data, StreamID, Data, true}} -> + {ok, <>}; + {quic, Conn, {stream_data, StreamID, Data, false}} -> + {ok, <>}; + {quic, Conn, {stream_closed, StreamID, _ErrorCode}} -> + {ok, Acc}; + {quic, Conn, _Other} -> + do_receive_data(Conn, StreamID, Acc, Timeout) + after Timeout -> + {error, timeout} + end. + +do_wait_stream_aborted(Conn, StreamID) -> + receive + {quic, Conn, {stream_closed, StreamID, ErrorCode}} -> + #{reason => do_error_code_to_reason(ErrorCode)}; + {quic, Conn, {stream_data, StreamID, _, _}} -> + do_wait_stream_aborted(Conn, StreamID); + {quic, Conn, _Other} -> + do_wait_stream_aborted(Conn, StreamID) + after 5000 -> + {error, timeout} + end. + +do_error_code_to_reason(16#0106) -> h3_message_error; +do_error_code_to_reason(Code) -> {unknown, Code}. + +accept_handshake_when_enabled(Config) -> + doc("Confirm the example for Websocket over HTTP/3 works. (RFC9220, RFC8441 5.1)"), + #{conn := Conn, settings := Settings} = do_connect(Config), + #{enable_connect_protocol := true} = Settings, + {ok, StreamID} = quic:open_stream(Conn), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, [ + <<1>>, + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], false), + {ok, Data} = do_receive_data(Conn, StreamID), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + _Rest/binary + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + Mask = 16#37fa213d, + MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>), + ok = quic:send_data(Conn, StreamID, cow_http3:data( + <<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>), false), + {ok, WsData} = do_receive_data(Conn, StreamID), + << + 0, + 0:2, 7:6, + 1:1, 0:3, 1:4, 0:1, 5:7, "Hello" + >> = WsData, + quic:close(Conn, 0), + ok. + +accept_uppercase_pseudo_header_protocol(Config) -> + doc("The :protocol pseudo header is case insensitive. (RFC9220, RFC8441 4, RFC9110 7.8)"), + #{conn := Conn, settings := Settings} = do_connect(Config), + #{enable_connect_protocol := true} = Settings, + {ok, StreamID} = quic:open_stream(Conn), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"WEBSOCKET">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, [ + <<1>>, + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], false), + {ok, Data} = do_receive_data(Conn, StreamID), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + _/binary + >> = Data, + {ok, DecodedResponse, _, _} = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + quic:close(Conn, 0), + ok. + +reject_unknown_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request with an unknown protocol " + "must be rejected with a 501 Not Implemented response. (RFC9220, RFC8441 4)"), + #{conn := Conn, settings := Settings} = do_connect(Config), + #{enable_connect_protocol := true} = Settings, + {ok, StreamID} = quic:open_stream(Conn), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, StreamID, [ + <<1>>, + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], true), + {ok, Data} = do_receive_data(Conn, StreamID), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + _/binary + >> = Data, + {ok, DecodedResponse, _, _} = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"501">>} = maps:from_list(DecodedResponse), + quic:close(Conn, 0), + ok. + +do_guess_int_encoding(<<0:2, _:6, _/bits>>) -> {0, 6}; +do_guess_int_encoding(<<1:2, _:14, _/bits>>) -> {1, 14}; +do_guess_int_encoding(<<2:2, _:30, _/bits>>) -> {2, 30}; +do_guess_int_encoding(<<3:2, _:62, _/bits>>) -> {3, 62}. diff --git a/test/webtransport_quic_SUITE.erl b/test/webtransport_quic_SUITE.erl new file mode 100644 index 000000000..dff10b12d --- /dev/null +++ b/test/webtransport_quic_SUITE.erl @@ -0,0 +1,209 @@ +%% Copyright (c) Loic Hoguin +%% Copyright (c) Benoit Chesneau +%% +%% 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(webtransport_quic_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}]. + +init_per_group(Name = enabled, Config) -> + cowboy_test:init_http3(Name, #{ + enable_connect_protocol => true, + h3_datagram => true, + enable_webtransport => true, + wt_max_sessions => 10, + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/wt", wt_echo_h, []} + ]} +]. + +do_connect(Config) -> + Port = config(port, Config), + {ok, Conn} = quic:connect("localhost", Port, + #{alpn => [<<"h3">>], verify => false}, self()), + receive + {quic, Conn, {connected, _}} -> ok + after 5000 -> + error(timeout_connect) + end, + {ok, SettingsBin, _} = cow_http3_machine:init(client, #{ + enable_connect_protocol => true, + h3_datagram => true + }), + {ok, ControlID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, ControlID, [<<0>>, SettingsBin], false), + {ok, EncoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, EncoderID, <<2>>, false), + {ok, DecoderID} = quic:open_unidirectional_stream(Conn), + ok = quic:send_data(Conn, DecoderID, <<3>>, false), + timer:sleep(100), + Settings = receive_server_settings(Conn, #{}), + #{conn => Conn, settings => Settings}. + +receive_server_settings(Conn, Acc) -> + receive + {quic, Conn, {stream_opened, _StreamID, unidirectional}} -> + receive_server_settings(Conn, Acc); + {quic, Conn, {stream_data, _StreamID, Data, _Fin}} -> + parse_settings(Data, Acc); + {quic, Conn, _Other} -> + receive_server_settings(Conn, Acc) + after 500 -> + Acc + end. + +parse_settings(<<0, Rest/binary>>, Acc) -> + parse_settings_frame(Rest, Acc); +parse_settings(<<_, _/binary>>, Acc) -> + Acc; +parse_settings(<<>>, Acc) -> + Acc. + +parse_settings_frame(<<4, Rest/binary>>, Acc) -> + {Len, Rest2} = cow_http3:parse_int(Rest), + <> = Rest2, + decode_settings(SettingsPayload, Acc); +parse_settings_frame(_, Acc) -> + Acc. + +decode_settings(<<>>, Acc) -> + Acc; +decode_settings(Bin, Acc) -> + {Id, Rest} = cow_http3:parse_int(Bin), + {Value, Rest2} = cow_http3:parse_int(Rest), + Acc2 = case Id of + 8 -> Acc#{enable_connect_protocol => Value =:= 1}; + 16#33 -> Acc#{h3_datagram => Value =:= 1}; + 16#2b603742 -> Acc#{wt_max_sessions => Value}; + _ -> Acc + end, + decode_settings(Rest2, Acc2). + +do_webtransport_connect(Config) -> + do_webtransport_connect(Config, []). + +do_webtransport_connect(Config, ExtraHeaders) -> + #{conn := Conn, settings := Settings} = do_connect(Config), + #{enable_connect_protocol := true} = Settings, + {ok, SessionID} = quic:open_stream(Conn), + Headers = [ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"webtransport">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/wt">>}, + {<<":authority">>, <<"localhost">>}, + {<<"origin">>, <<"https://localhost">>} + ] ++ ExtraHeaders, + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section( + Headers, 0, cow_qpack:init(encoder)), + ok = quic:send_data(Conn, SessionID, [ + <<1>>, + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], false), + {ok, Data} = do_receive_data(Conn, SessionID), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + _Rest/binary + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} = + cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + RespHeaders = DecodedResponse, + #{<<":status">> := <<"200">>} = maps:from_list(RespHeaders), + #{ + conn => Conn, + session_id => SessionID, + resp_headers => RespHeaders, + settings => Settings + }. + +do_receive_data(Conn, StreamID) -> + do_receive_data(Conn, StreamID, <<>>, 5000). + +do_receive_data(Conn, StreamID, Acc, Timeout) -> + receive + {quic, Conn, {stream_data, StreamID, Data, true}} -> + {ok, <>}; + {quic, Conn, {stream_data, StreamID, Data, false}} -> + {ok, <>}; + {quic, Conn, {stream_closed, StreamID, _ErrorCode}} -> + {ok, Acc}; + {quic, Conn, _Other} -> + do_receive_data(Conn, StreamID, Acc, Timeout) + after Timeout -> + {error, timeout} + end. + +do_guess_int_encoding(<<0:2, _:6, _/bits>>) -> {0, 6}; +do_guess_int_encoding(<<1:2, _:14, _/bits>>) -> {1, 14}; +do_guess_int_encoding(<<2:2, _:30, _/bits>>) -> {2, 30}; +do_guess_int_encoding(<<3:2, _:62, _/bits>>) -> {3, 62}. + +accept_session_when_enabled(Config) -> + doc("Confirm that a WebTransport session can be established over HTTP/3. " + "(draft_webtrans_http3 3.3, RFC9220)"), + #{ + conn := Conn, + session_id := _SessionID, + settings := Settings + } = do_webtransport_connect(Config), + true = maps:get(enable_connect_protocol, Settings, false), + quic:close(Conn, 0), + ok. + +settings_enable_connect_protocol(Config) -> + doc("Server must send SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 for WebTransport. " + "(draft_webtrans_http3 3.2)"), + #{conn := Conn, settings := Settings} = do_connect(Config), + #{enable_connect_protocol := true} = Settings, + quic:close(Conn, 0), + ok. + +application_protocol_negotiation(Config) -> + doc("Applications can negotiate a protocol to use via WebTransport. " + "(draft_webtrans_http3 3.4)"), + WTAvailableProtocols = cow_http_hd:wt_available_protocols([<<"foo">>, <<"bar">>]), + #{ + conn := Conn, + resp_headers := RespHeaders + } = do_webtransport_connect(Config, [{<<"wt-available-protocols">>, WTAvailableProtocols}]), + case lists:keyfind(<<"wt-protocol">>, 1, RespHeaders) of + {<<"wt-protocol">>, WTProtocol} -> + Protocol = iolist_to_binary(cow_http_hd:parse_wt_protocol(WTProtocol)), + true = lists:member(Protocol, [<<"foo">>, <<"bar">>]); + false -> + ok + end, + quic:close(Conn, 0), + ok.