diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f20a63f6..6a2eb0c5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,11 +4,16 @@ name: Check Cowboy on: push: + branches: + - master pull_request: schedule: ## Every Monday at 2am. - cron: 0 2 * * 1 +env: + CI_ERLANG_MK: 1 + jobs: cleanup-master: name: Cleanup master build @@ -29,3 +34,37 @@ jobs: name: Cowboy needs: cleanup-master uses: ninenines/ci.erlang.mk/.github/workflows/ci.yaml@master + +# The examples test suite is nice to run but typically not +# important. So we run them after we are done with the other +# test suites. At this point we know that Erlang was built +# so we can just use the latest version. + + examples: + name: Check examples + needs: check + runs-on: 'ubuntu-latest' + if: ${{ !cancelled() }} + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Output latest Erlang/OTP version + id: latest_version + run: | + { + echo "latest<> "$GITHUB_OUTPUT" + + - name: Restore CI cache + uses: actions/cache/restore@v4 + with: + path: | + ~/erlang/ + key: ${{ runner.os }}-${{ runner.arch }}-Erlang-${{ steps.latest_version.outputs.latest }} + + - name: Run ct-examples + run: make ct-examples LATEST_ERLANG_OTP=1 diff --git a/LICENSE b/LICENSE index 0b6647fb..efeaf45a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011-2022, Loïc Hoguin +Copyright (c) 2011-2024, Loïc Hoguin Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/Makefile b/Makefile index 0a43187c..6ffdfc4f 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ PROJECT = cowboy PROJECT_DESCRIPTION = Small, fast, modern HTTP server. -PROJECT_VERSION = 2.10.0 +PROJECT_VERSION = 2.12.0 PROJECT_REGISTERED = cowboy_clock # Options. @@ -15,9 +15,14 @@ CT_OPTS += -ct_hooks cowboy_ct_hook [] # -boot start_sasl LOCAL_DEPS = crypto DEPS = cowlib ranch -dep_cowlib = git https://github.com/ninenines/cowlib 2.12.1 +dep_cowlib = git https://github.com/ninenines/cowlib master dep_ranch = git https://github.com/ninenines/ranch 1.8.0 +ifeq ($(COWBOY_QUICER),1) +DEPS += quicer +dep_quicer = git https://github.com/emqx/quic main +endif + DOC_DEPS = asciideck TEST_DEPS = $(if $(CI_ERLANG_MK),ci.erlang.mk) ct_helper gun @@ -38,8 +43,8 @@ define HEX_TARBALL_EXTRA_METADATA #{ licenses => [<<"ISC">>], links => #{ - <<"User guide">> => <<"https://ninenines.eu/docs/en/cowboy/2.10/guide/">>, - <<"Function reference">> => <<"https://ninenines.eu/docs/en/cowboy/2.10/manual/">>, + <<"User guide">> => <<"https://ninenines.eu/docs/en/cowboy/2.12/guide/">>, + <<"Function reference">> => <<"https://ninenines.eu/docs/en/cowboy/2.12/manual/">>, <<"GitHub">> => <<"https://github.com/ninenines/cowboy">>, <<"Sponsor">> => <<"https://github.com/sponsors/essen">> } @@ -50,21 +55,37 @@ endef include erlang.mk -# Don't run the examples test suite by default. +# Don't run the examples/autobahn test suites by default. ifndef FULL CT_SUITES := $(filter-out examples ws_autobahn,$(CT_SUITES)) endif +# Don't run HTTP/3 test suites on Windows. + +ifeq ($(PLATFORM),msys2) +CT_SUITES := $(filter-out rfc9114 rfc9204 rfc9220,$(CT_SUITES)) +endif + # Compile options. 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 +# Fix quicer compilation for HTTP/3. + +autopatch-quicer:: + $(verbose) printf "%s\n" "all: ;" > $(DEPS_DIR)/quicer/c_src/Makefile.erlang.mk + # Dialyze the tests. #DIALYZER_OPTS += --src -r test @@ -101,8 +122,11 @@ prepare_tag: $(verbose) echo -n "GUIDE: " $(verbose) grep -h dep_$(PROJECT)_commit doc/src/guide/*.asciidoc || true $(verbose) echo + $(verbose) echo "Links in the README:" + $(verbose) grep http.*:// README.asciidoc + $(verbose) echo $(verbose) echo "Titles in most recent CHANGELOG:" - $(verbose) for f in `ls -r doc/src/guide/migrating_from_*.asciidoc | head -n1`; do \ + $(verbose) for f in `ls -rv doc/src/guide/migrating_from_*.asciidoc | head -n1`; do \ echo $$f:; \ grep == $$f; \ done diff --git a/README.asciidoc b/README.asciidoc index cd110adc..02acaa69 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -18,8 +18,8 @@ Cowboy is *clean* and *well tested* Erlang code. == Online documentation -* https://ninenines.eu/docs/en/cowboy/2.6/guide[User guide] -* https://ninenines.eu/docs/en/cowboy/2.6/manual[Function reference] +* https://ninenines.eu/docs/en/cowboy/2.12/guide[User guide] +* https://ninenines.eu/docs/en/cowboy/2.12/manual[Function reference] == Offline documentation diff --git a/doc/src/guide/book.asciidoc b/doc/src/guide/book.asciidoc index 582820f0..cf8c943e 100644 --- a/doc/src/guide/book.asciidoc +++ b/doc/src/guide/book.asciidoc @@ -75,6 +75,10 @@ include::performance.asciidoc[Performance] = Additional information +include::migrating_from_2.11.asciidoc[Migrating from Cowboy 2.11 to 2.12] + +include::migrating_from_2.10.asciidoc[Migrating from Cowboy 2.10 to 2.11] + include::migrating_from_2.9.asciidoc[Migrating from Cowboy 2.9 to 2.10] include::migrating_from_2.8.asciidoc[Migrating from Cowboy 2.8 to 2.9] diff --git a/doc/src/guide/getting_started.asciidoc b/doc/src/guide/getting_started.asciidoc index 4bdde923..a26802de 100644 --- a/doc/src/guide/getting_started.asciidoc +++ b/doc/src/guide/getting_started.asciidoc @@ -62,14 +62,16 @@ handler. === Cowboy setup We will modify the 'Makefile' to tell the build system it needs to -fetch and compile Cowboy: +fetch and compile Cowboy, and that we will use releases: [source,makefile] ---- PROJECT = hello_erlang DEPS = cowboy -dep_cowboy_commit = 2.10.0 +dep_cowboy_commit = 2.11.0 + +REL_DEPS = relx DEP_PLUGINS = cowboy @@ -80,6 +82,9 @@ The `DEP_PLUGINS` line tells the build system to load the plugins Cowboy provides. These include predefined templates that we will use soon. +The `REL_DEPS` line tells the build system to fetch and build +`relx`, the library that will create the release. + If you do `make run` now, Cowboy will be included in the release and started automatically. This is not enough however, as Cowboy doesn't do anything by default. We still need to tell Cowboy to diff --git a/doc/src/guide/introduction.asciidoc b/doc/src/guide/introduction.asciidoc index f81c8727..519608d1 100644 --- a/doc/src/guide/introduction.asciidoc +++ b/doc/src/guide/introduction.asciidoc @@ -42,7 +42,7 @@ Cowboy is developed for Erlang/OTP 22.0 and newer. Cowboy uses the ISC License. ---- -Copyright (c) 2011-2019, Loïc Hoguin +Copyright (c) 2011-2024, Loïc Hoguin Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/doc/src/guide/migrating_from_2.10.asciidoc b/doc/src/guide/migrating_from_2.10.asciidoc new file mode 100644 index 00000000..aaa8fe9d --- /dev/null +++ b/doc/src/guide/migrating_from_2.10.asciidoc @@ -0,0 +1,139 @@ +[appendix] +== Migrating from Cowboy 2.10 to 2.11 + +Cowboy 2.11 contains a variety of new features and bug +fixes. Nearly all previously experimental features are +now marked as stable, including Websocket over HTTP/2. +Included is a fix for an HTTP/2 protocol CVE. + +Cowboy 2.11 requires Erlang/OTP 24.0 or greater. + +Cowboy is now using GitHub Actions for CI. The main reason +for the move is to reduce costs by no longer having to +self-host CI runners. The downside is that GitHub runners +are less reliable and timing dependent tests are now more +likely to fail. + +=== Features added + +* A new HTTP/2 option `max_cancel_stream_rate` has been added + to control the rate of stream cancellation the server will + accept. By default Cowboy will accept 500 cancelled streams + every 10 seconds. + +* A new stream handler `cowboy_decompress_h` has been added. + It allows automatically decompressing incoming gzipped + request bodies. It includes options to protect against + zip bombs. + +* Websocket over HTTP/2 is no longer considered experimental. + Note that the `enable_connect_protocol` option must be set + to `true` in order to use Websocket over HTTP/2 for the + time being. + +* Automatic mode for reading request bodies has been + documented. In automatic mode, Cowboy waits indefinitely + for data and sends a `request_body` message when data + comes in. It mirrors `{active, once}` socket modes. + This is ideal for loop handlers and is also used + internally for HTTP/2 Websocket. + +* Ranged requests support is no longer considered + experimental. It was added in 2.6 to both `cowboy_static` + and `cowboy_rest`. Ranged responses can be produced + either automatically (for the `bytes` unit) or manually. + REST flowcharts have been updated with the new callbacks + and steps related to handling ranged requests. + +* A new HTTP/1.1 and HTTP/2 option `reset_idle_timeout_on_send` + has been added. When enabled, the `idle_timeout` will be + reset every time Cowboy sends data to the socket. + +* Loop handlers may now return a timeout value in the place + of `hibernate`. Timeouts behave the same as in `gen_server`. + +* The `generate_etag` callback of REST handlers now accepts + `undefined` as a return value to allow conditionally + generating etags. + +* The `cowboy_compress_h` options `compress_threshold` and + `compress_buffering` are no longer considered experimental. + They were de facto stable since 2.6 as they already were + documented. + +* Functions `cowboy:get_env/2,3` have been added. + +* Better error messages have been added when trying to send + a 204 or 304 response with a body; when attempting to + send two responses to a single request; when trying to + push a response after the final response; when trying + to send a `set-cookie` header without using + `cowboy_req:set_resp_cookie/3,4`. + +=== Features removed + +* Cowboy will no longer include the NPN extension when + starting a TLS listener. This extension has long been + deprecated and replaced with the ALPN extension. Cowboy + will continue using the ALPN extension for protocol + negotiation. + +=== Bugs fixed + +* A fix was made to address the HTTP/2 CVE CVE-2023-44487 + via the new HTTP/2 option `max_cancel_stream_rate`. + +* HTTP/1.1 requests that contain both a content-length and + a transfer-encoding header will now be rejected to avoid + security risks. Previous behavior was to ignore the + content-length header as recommended by the HTTP RFC. + +* HTTP/1.1 connections would sometimes use the wrong timeout + value to determine whether the connection should be closed. + This resulted in connections staying up longer than + intended. This should no longer be the case. + +* Cowboy now reacts to socket errors immediately for HTTP/1.1 + and HTTP/2 when possible. Cowboy will notice when connections + have been closed properly earlier than before. This also + means that the socket option `send_timeout_close` will work + as expected. + +* Shutting down HTTP/1.1 pipelined requests could lead to + the current request being terminated before the response + has been sent. This has been addressed. + +* When using HTTP/1.1 an invalid Connection header will now + be rejected with a 400 status code instead of crashing. + +* The documentation now recommends increasing the HTTP/2 + option `max_frame_size_received`. Cowboy currently uses + the protocol default but will increase its default in a + future release. Until then users are recommended to set + the option to ensure larger requests are accepted and + processed with acceptable performance. + +* Cowboy could sometimes send HTTP/2 WINDOW_UPDATE frames + twice in a row. Now they should be consolidated. + +* Cowboy would sometimes send HTTP/2 WINDOW_UPDATE frames + for streams that have stopped internally. This should + no longer be the case. + +* The `cowboy_compress_h` stream handler will no longer + attempt to compress responses that have an `etag` header + to avoid caching issues. + +* The `cowboy_compress_h` will now always add `accept-encoding` + to the `vary` header as it indicates that responses may + be compressed. + +* Cowboy will now remove the `trap_exit` process flag when + HTTP/1.1 connections upgrade to Websocket. + +* Exit gracefully instead of crashing when the socket gets + closed when reading the PROXY header. + +* Missing `cowboy_stream` manual pages have been added. + +* A number of fixes were made to documentation and examples. diff --git a/doc/src/guide/migrating_from_2.11.asciidoc b/doc/src/guide/migrating_from_2.11.asciidoc new file mode 100644 index 00000000..ab746426 --- /dev/null +++ b/doc/src/guide/migrating_from_2.11.asciidoc @@ -0,0 +1,15 @@ +[appendix] +== Migrating from Cowboy 2.11 to 2.12 + +Cowboy 2.12 contains a small security improvement for +the HTTP/2 protocol. + +Cowboy 2.12 requires Erlang/OTP 24.0 or greater. + +=== Features added + +* A new HTTP/2 option `max_fragmented_header_block_size` has + been added to limit the size of header blocks that are + sent over multiple HEADERS and CONTINUATION frames. + +* Update Cowlib to 2.13.0. diff --git a/doc/src/guide/resource_design.asciidoc b/doc/src/guide/resource_design.asciidoc index 954d87d5..125b437c 100644 --- a/doc/src/guide/resource_design.asciidoc +++ b/doc/src/guide/resource_design.asciidoc @@ -144,6 +144,16 @@ never be called. Implement the `languages_provided` or `charsets_provided` callbacks if applicable. +Does the resource accept ranged requests? If it does, +implement the `ranges_provided` callback. Resources that +only accept `bytes` units can use the callback name +`auto` and let Cowboy automatically do ranged responses. +Other callbacks should have a name prefix of `ranged_` +for clarity. For example, `ranged_bytes` or `ranged_pages`. +If the resource needs to perform additional checks before +accepting to do a ranged responses, implement the +`range_satisfiable` callback. + Is there any other header that may make the representation of the resource vary? Implement the `variances` callback. @@ -191,10 +201,15 @@ the `options` method. === GET and HEAD methods If you implement the methods GET and/or HEAD, you must -implement one `ProvideResource` callback for each +implement one `ProvideCallback` callback for each content-type returned by the `content_types_provided` callback. +When range requests are accepted, you must implement one +`RangeCallback` for each range unit returned by +`ranges_provided` (unless `auto` was used). This is +in addition to the `ProvideCallback` callback. + === PUT, POST and PATCH methods If you implement the methods PUT, POST and/or PATCH, diff --git a/doc/src/guide/rest_conneg.png b/doc/src/guide/rest_conneg.png index 65ecdcf3..79aa69b6 100644 Binary files a/doc/src/guide/rest_conneg.png and b/doc/src/guide/rest_conneg.png differ diff --git a/doc/src/guide/rest_conneg.svg b/doc/src/guide/rest_conneg.svg index 247567a0..97bba6a3 100644 --- a/doc/src/guide/rest_conneg.svg +++ b/doc/src/guide/rest_conneg.svg @@ -2,24 +2,23 @@ + inkscape:export-ydpi="90" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + inkscape:swatch="solid"> + showguides="true" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1"> + snapvisiblegridlinesonly="true" + originx="0" + originy="0" + spacingy="1" + spacingx="1" + units="px" /> @@ -93,7 +100,7 @@ image/svg+xml - + @@ -112,7 +119,7 @@ some text + y="114.39204" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text some text + y="53.112247" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text has accept-language? + y="310.19913" + style="font-size:16px;line-height:1.25;font-family:sans-serif">has accept-language? has accept-charset? + y="477.47531" + style="font-size:16px;line-height:1.25;font-family:sans-serif">has accept-charset? some text + y="236.95154" + style="font-size:16px;line-height:1.25;font-family:sans-serif">some text start + id="tspan17171" + style="font-size:16px;line-height:1.25;font-family:sans-serif">start charsets_provided + y="561.14258" + style="font-size:16px;line-height:1.25;font-family:sans-serif">charsets_provided variances + y="646.58331" + style="font-size:16px;line-height:1.25;font-family:sans-serif">ranges_provided has accept? + y="142.80627" + style="font-size:16px;line-height:1.25;font-family:sans-serif">has accept? content_types_provided + y="226.4736" + style="font-size:16px;line-height:1.25;font-family:sans-serif">content_types_provided languages_provided + y="393.80801" + style="font-size:16px;line-height:1.25;font-family:sans-serif">languages_provided true + y="185.95248" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true provided* + y="269.61978" + style="font-size:16px;line-height:1.25;font-family:sans-serif">provided* true + y="353.28702" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true provided* + y="436.95425" + style="font-size:16px;line-height:1.25;font-family:sans-serif">provided* true + y="520.62152" + style="font-size:16px;line-height:1.25;font-family:sans-serif">true provided* + y="604.28876" + style="font-size:16px;line-height:1.25;font-family:sans-serif">provided* @@ -820,75 +827,75 @@ false + y="227.88033" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false false + y="395.20209" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false not provided* + y="374.19577" + style="font-size:16px;line-height:1.25;font-family:sans-serif">not provided* false + y="562.52386" + style="font-size:16px;line-height:1.25;font-family:sans-serif">false not provided* + y="663.24762" + style="font-size:16px;line-height:1.25;font-family:sans-serif">not provided* 406 not acceptable + y="394.09869" + style="font-size:16px;line-height:1.25;font-family:sans-serif">406 not acceptable middlewares + y="-354.17184" + style="font-size:16px;line-height:1.25;font-family:sans-serif">middlewares not provided* + y="-106.16136" + style="font-size:16px;line-height:1.25;font-family:sans-serif">not provided* + + + + ... + y="730.10156" + style="font-size:16px;line-height:1.25;font-family:sans-serif">variances + + ... diff --git a/doc/src/guide/rest_flowcharts.asciidoc b/doc/src/guide/rest_flowcharts.asciidoc index 308a919e..b8d0e0d5 100644 --- a/doc/src/guide/rest_flowcharts.asciidoc +++ b/doc/src/guide/rest_flowcharts.asciidoc @@ -95,6 +95,11 @@ callback will only be called at the end of the "GET and HEAD methods" diagram, when all conditions have been met. +Optionally, the `ranges_provided` also returns the +name of a callback for every range unit it accepts. This +will be called at the end of the "GET and HEAD methods" +diagram in the case of ranged requests. + The selected content-type, language and charset are saved as meta values in the Req object. You *should* use the appropriate representation if you set a @@ -121,11 +126,18 @@ succeed, the resource can be retrieved. Cowboy prepares the response by first retrieving metadata about the representation, then by calling -the `ProvideResource` callback. This is the callback +the `ProvideCallback` callback. This is the callback you defined for each content-types you returned from `content_types_provided`. This callback returns the body -that will be sent back to the client, or a fun if the -body must be streamed. +that will be sent back to the client. + +For ranged requests, but only when the `ranges_provided` +callback was defined earlier, Cowboy will add the selected +`range` information to the Req object and call the +`range_satisfiable` callback. After confirming that the +range can be provided, Cowboy calls the `RangeResource` +callback and produces a ranged response using the +ranged data from the callback. When the resource does not exist, Cowboy will figure out whether the resource existed previously, and if so whether diff --git a/doc/src/guide/rest_get_head.png b/doc/src/guide/rest_get_head.png index 211ab603..24f8de45 100644 Binary files a/doc/src/guide/rest_get_head.png and b/doc/src/guide/rest_get_head.png differ diff --git a/doc/src/guide/rest_get_head.svg b/doc/src/guide/rest_get_head.svg index 92030cf3..cf660897 100644 --- a/doc/src/guide/rest_get_head.svg +++ b/doc/src/guide/rest_get_head.svg @@ -2,24 +2,23 @@ + inkscape:export-ydpi="90" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/"> + inkscape:swatch="solid"> + showguides="true" + inkscape:showpageshadow="2" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1"> + snapvisiblegridlinesonly="true" + originx="0" + originy="0" + spacingy="1" + spacingx="1" + units="px" /> @@ -101,6 +108,223 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + transform="rotate(90,-13.918835,262.77429)" /> + d="m -57.78256,343.20394 v 61.59661" + style="opacity:0.8;fill:none;stroke:#9b3b1c;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> last_modified ProvideCallback + + ProvideCallback conneg multiple_choices resource_exists generate_etag expires + + + + + + has range? + + + + + + resource provides ranges? + + + + + + has if-range? + + + + + + requested range provided? + + + + + + range_satisfiable + + + + + + + + + + automatic range? true true + true + true + false, or + true + true + false + false false + false + false + false + false + no match + error producingautomaticranged response + d="m -57.78256,343.20394 v 61.59661" + style="opacity:0.8;fill:none;stroke:#9b3b1c;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> true true + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> cond 300 multiple choices 200 OK + automaticranged response + strong etag match + + 206 partial content + + RangeCallback + + 206 partial content + transform="rotate(90,-13.918835,262.77429)" /> has if-match? false + d="m -57.78256,351.41962 v 52.3259" + style="opacity:0.8;fill:none;stroke:#6d8e41;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + transform="rotate(90,-13.918835,262.77429)" /> previously_existed 404 not found false + d="m -57.78256,343.20394 v 61.59661" + style="opacity:0.8;fill:none;stroke:#9b3b1c;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + transform="rotate(90,-13.918835,262.77429)" /> moved_permanently 412 precondition failed true true* false 301 moved permanently + d="m -57.78256,343.20394 v 61.59661" + style="opacity:0.8;fill:none;stroke:#9b3b1c;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + transform="rotate(90,-13.918835,262.77429)" /> moved_temporarily true* false 307 moved temporarily 410 gone + + 416 not satisfiable diff --git a/doc/src/guide/rest_handlers.asciidoc b/doc/src/guide/rest_handlers.asciidoc index baf8e6a2..19a98596 100644 --- a/doc/src/guide/rest_handlers.asciidoc +++ b/doc/src/guide/rest_handlers.asciidoc @@ -84,6 +84,8 @@ if it is undefined, moving directly to the next step. Similarly, | multiple_choices | `false` | options | `ok` | previously_existed | `false` +| ranges_provided | skip +| range_satisfiable | `true` | rate_limited | `false` | resource_exists | `true` | service_available | `true` @@ -97,8 +99,9 @@ As you can see, Cowboy tries to move on with the request whenever possible by using well thought out default values. In addition to these, there can be any number of user-defined -callbacks that are specified through `content_types_accepted/2` -and `content_types_provided/2`. They can take any name, however +callbacks that are specified through `content_types_accepted/2`, +`content_types_provided/2` or `ranges_provided/2`. They can take +any name (except `auto` for range callbacks), however it is recommended to use a separate prefix for the callbacks of each function. For example, `from_html` and `to_html` indicate in the first case that we're accepting a resource given as HTML, @@ -113,9 +116,10 @@ Req object directly. The values are defined in the following table: [cols="<,<",options="header"] |=== | Key | Details -| media_type | The content-type negotiated for the response entity. -| language | The language negotiated for the response entity. -| charset | The charset negotiated for the response entity. +| media_type | The content-type negotiated for the response entity +| language | The language negotiated for the response entity +| charset | The charset negotiated for the response entity +| range | The range selected for the ranged response |=== They can be used to send a proper body with the response to a @@ -129,11 +133,16 @@ of the REST code. They are listed in the following table. [cols="<,<",options="header"] |=== | Header name | Details +| accept-ranges | Range units accepted by the resource +| allow | HTTP methods allowed by the resource | content-language | Language used in the response body +| content-range | Range of the content found in the response | content-type | Media type and charset of the response body | etag | Etag of the resource | expires | Expiration date of the resource | last-modified | Last modification date for the resource | location | Relative or absolute URI to the requested resource +| retry-after | Delay or time the client should wait before accessing the resource | vary | List of headers that may change the representation of the resource +| www-authenticate | Authentication information to access the resource |=== diff --git a/doc/src/manual/cowboy_http.asciidoc b/doc/src/manual/cowboy_http.asciidoc index 33d2888c..58f0435f 100644 --- a/doc/src/manual/cowboy_http.asciidoc +++ b/doc/src/manual/cowboy_http.asciidoc @@ -17,27 +17,28 @@ as a Ranch protocol. [source,erlang] ---- opts() :: #{ - active_n => pos_integer(), - chunked => boolean(), - connection_type => worker | supervisor, - http10_keepalive => boolean(), - idle_timeout => timeout(), - inactivity_timeout => timeout(), - initial_stream_flow_size => non_neg_integer(), - linger_timeout => timeout(), - logger => module(), - max_empty_lines => non_neg_integer(), - max_header_name_length => non_neg_integer(), - max_header_value_length => non_neg_integer(), - max_headers => non_neg_integer(), - max_keepalive => non_neg_integer(), - max_method_length => non_neg_integer(), - max_request_line_length => non_neg_integer(), - max_skip_body_length => non_neg_integer(), - proxy_header => boolean(), - request_timeout => timeout(), - sendfile => boolean(), - stream_handlers => [module()] + active_n => pos_integer(), + chunked => boolean(), + connection_type => worker | supervisor, + http10_keepalive => boolean(), + idle_timeout => timeout(), + inactivity_timeout => timeout(), + initial_stream_flow_size => non_neg_integer(), + linger_timeout => timeout(), + logger => module(), + max_empty_lines => non_neg_integer(), + max_header_name_length => non_neg_integer(), + max_header_value_length => non_neg_integer(), + max_headers => non_neg_integer(), + max_keepalive => non_neg_integer(), + max_method_length => non_neg_integer(), + max_request_line_length => non_neg_integer(), + max_skip_body_length => non_neg_integer(), + proxy_header => boolean(), + request_timeout => timeout(), + reset_idle_timeout_on_send => boolean(), + sendfile => boolean(), + stream_handlers => [module()] } ---- @@ -148,6 +149,11 @@ request_timeout (5000):: Time in ms with no requests before Cowboy closes the connection. +reset_idle_timeout_on_send (false):: + +Whether the `idle_timeout` gets reset when sending data +to the socket. + sendfile (true):: Whether the sendfile syscall may be used. It can be useful to disable @@ -160,6 +166,7 @@ Ordered list of stream handlers that will handle all stream events. == Changelog +* *2.11*: The `reset_idle_timeout_on_send` option was added. * *2.8*: The `active_n` option was added. * *2.7*: The `initial_stream_flow_size` and `logger` options were added. * *2.6*: The `chunked`, `http10_keepalive`, `proxy_header` and `sendfile` options were added. diff --git a/doc/src/manual/cowboy_http2.asciidoc b/doc/src/manual/cowboy_http2.asciidoc index 6af4fc00..1d2619ca 100644 --- a/doc/src/manual/cowboy_http2.asciidoc +++ b/doc/src/manual/cowboy_http2.asciidoc @@ -35,6 +35,7 @@ opts() :: #{ max_connection_window_size => 0..16#7fffffff, max_decode_table_size => non_neg_integer(), max_encode_table_size => non_neg_integer(), + max_fragmented_header_block_size => 16384..16#7fffffff, max_frame_size_received => 16384..16777215, max_frame_size_sent => 16384..16777215 | infinity, max_received_frame_rate => {pos_integer(), timeout()}, @@ -44,6 +45,7 @@ opts() :: #{ max_stream_window_size => 0..16#7fffffff, preface_timeout => timeout(), proxy_header => boolean(), + reset_idle_timeout_on_send => boolean(), sendfile => boolean(), settings_timeout => timeout(), stream_handlers => [module()], @@ -93,7 +95,10 @@ enable_connect_protocol (false):: Whether to enable the extended CONNECT method to allow protocols like Websocket to be used over an HTTP/2 stream. -This option is experimental and disabled by default. ++ +For backward compatibility reasons, this option is disabled +by default. It must be enabled to use Websocket over HTTP/2. +It will be enabled by default in a future release. goaway_initial_timeout (1000):: @@ -168,6 +173,14 @@ Maximum header table size in bytes used by the encoder. The server will compare this value to what the client advertises and choose the smallest one as the encoder's header table size. +max_fragmented_header_block_size (32768):: + +Maximum header block size when headers are split over multiple HEADERS +and CONTINUATION frames. Clients that attempt to send header blocks +larger than this value will receive an ENHANCE_YOUR_CALM connection +error. Note that this value is not advertised and should be large +enough for legitimate requests. + max_frame_size_received (16384):: Maximum size in bytes of the frames received by the server. This value is @@ -235,6 +248,11 @@ Whether incoming connections have a PROXY protocol header. The proxy information will be passed forward via the `proxy_header` key of the Req object. +reset_idle_timeout_on_send (false):: + +Whether the `idle_timeout` gets reset when sending data +to the socket. + sendfile (true):: Whether the sendfile syscall may be used. It can be useful to disable @@ -271,6 +289,8 @@ too many `WINDOW_UPDATE` frames. == Changelog +* *2.11*: Websocket over HTTP/2 is now considered stable. +* *2.11*: The `reset_idle_timeout_on_send` option was added. * *2.11*: Add the option `max_cancel_stream_rate` to protect against another flood scenario. * *2.9*: The `goaway_initial_timeout` and `goaway_complete_timeout` @@ -300,7 +320,7 @@ too many `WINDOW_UPDATE` frames. `max_frame_size_received`, `max_frame_size_sent` and `settings_timeout` to configure HTTP/2 SETTINGS and related behavior. -* *2.4*: Add the experimental option `enable_connect_protocol`. +* *2.4*: Add the option `enable_connect_protocol`. * *2.0*: Protocol introduced. == See also diff --git a/doc/src/manual/cowboy_rest.asciidoc b/doc/src/manual/cowboy_rest.asciidoc index ea6dba93..fcef7997 100644 --- a/doc/src/manual/cowboy_rest.asciidoc +++ b/doc/src/manual/cowboy_rest.asciidoc @@ -605,17 +605,139 @@ The response body can be provided either as the actual data to be sent or a tuple indicating which file to send. This function is called for both GET and HEAD requests. For -the latter the body is not sent, however. +the latter the body is not sent: it is only used to calculate +the content length. // @todo Perhaps we can optimize HEAD requests and just // allow calculating the length instead of returning the // whole thing. -Note that there used to be a way to stream the response body. -It was temporarily removed and will be added back in a later -release. +It is possible to stream the response body either by manually +sending the response and returning a `stop` value; or by +switching to a different handler (for example a loop handler) +and manually sending the response. All headers already set +by Cowboy will also be included in the response. -// @todo Add a way to switch to loop handler for streaming the body. +== RangeCallback + +[source,erlang] +---- +RangeCallback(Req, State) -> {Result, Req, State} + +Result :: [{Range, Body}] +Range :: {From, To, Total} | binary() +From :: non_neg_integer() +To :: non_neg_integer() +Total :: non_neg_integer() | '*' +Body :: cowboy_req:resp_body() +Default - crash +---- + +Return a list of ranges for the response body. + +The range selected can be found in the key `range` +in the Req object, as indicated in `range_satisfiable`. + +Instead of returning the full response body as would +be done in the `ProvideCallback`, a list of ranges +must be returned. There can be one or more range. +When one range is returned, a normal ranged response +is sent. When multiple ranges are returned, Cowboy +will automatically send a multipart/byteranges +response. + +When the total is not known the atom `'*'` can be +returned. + +== ranges_provided + +[source,erlang] +---- +ranges_provided(Req, State) -> {Result, Req, State} + +Result :: [Range | Auto] +Range :: { + binary(), %% lowercase; case insensitive + RangeCallback :: atom() +} +Auto :: {<<"bytes">>, auto} +Default - skip this step +---- + +Return the list of range units the resource provides. + +During content negotiation Cowboy will build an accept-ranges +response header with the list of ranges provided. Cowboy +does not choose a range at this time; ranges are choosen +when it comes time to call the `ProvideCallback`. + +By default ranged requests will be handled the same as normal +requests: the `ProvideCallback` will be called and the full +response body will be sent. + +It is possible to let Cowboy handle ranged responses +automatically when the range unit is bytes and the +atom returned is `auto` (instead of a callback name). +In that case Cowboy will call the `ProvideCallback` +and split the response automatically, including by +producing a multipart/byteranges response if necessary. + +== range_satisfiable + +[source,erlang] +---- +range_satisfiable(Req, State) -> {Result, Req, State} + +Result :: boolean() | {false, non_neg_integer() | iodata()} +Default :: true +---- + +Whether the range request is satisfiable. + +When the time comes to send the response body, and when +ranges have been provided via the `ranges_provided` +callback, Cowboy will process the if-range and the +range request headers and ensure it is satisfiable. + +This callback allows making resource-specific checks +before sending the ranged response. The default is +to accept sending a ranged response. + +Cowboy adds the requested `range` to the Req object +just before calling this callback: + +[source,erlang] +---- +req() :: #{ + range => { + binary(), %% lowercase; case insensitive + Range + } +} + +Range :: ByteRange | binary() + +ByteRange :: [{FirstByte, LastByte | infinity} | SuffixLen] +FirstByte :: non_neg_integer() +LastByte :: non_neg_integer() +SuffixLen :: neg_integer() +---- + +Only byte ranges are parsed. Other ranges are provided +as binary. Byte ranges may either be requested from first +to last bytes (inclusive); from first bytes to the end +(`infinity` is used to represent the last byte); or +the last bytes of the representation via a negative +integer (so -500 means the last 500 bytes). + +Returning `false` will result in a 416 Range Not Satisfiable +response being sent. The content-range header will be +set automatically in the response if a tuple is +returned. The integer value represents the total +size (in the choosen unit) of the resource. An +iodata value may also be returned and will be +used as-is to build the content range header, +prepended with the unit choosen. === rate_limited @@ -625,7 +747,7 @@ rate_limited(Req, State) -> {Result, Req, State} Result :: false | {true, RetryAfter} RetryAfter :: non_neg_integer() | calendar:datetime() -Default - false +Default :: false ---- Return whether the user is rate limited. @@ -734,6 +856,8 @@ listed here, like the authorization header. == Changelog +* *2.11*: The `ranges_provided`, `range_satisfiable` and + the `RangeCallback` callbacks have been added. * *2.11*: The `generate_etag` callback can now return `undefined` to conditionally avoid generating an etag. diff --git a/doc/src/manual/cowboy_static.asciidoc b/doc/src/manual/cowboy_static.asciidoc index 0e131dd1..dde3401b 100644 --- a/doc/src/manual/cowboy_static.asciidoc +++ b/doc/src/manual/cowboy_static.asciidoc @@ -129,6 +129,8 @@ when it fails to detect a file's MIME type. == Changelog +* *2.11*: Support for range requests was added in 2.6 and + is now considered stable. * *2.6*: The `charset` extra option was added. * *1.0*: Handler introduced. diff --git a/doc/src/manual/cowboy_websocket.asciidoc b/doc/src/manual/cowboy_websocket.asciidoc index b1eb5930..6d822d9a 100644 --- a/doc/src/manual/cowboy_websocket.asciidoc +++ b/doc/src/manual/cowboy_websocket.asciidoc @@ -285,6 +285,7 @@ normal circumstances if necessary. == Changelog +* *2.11*: Websocket over HTTP/2 is now considered stable. * *2.11*: HTTP/1.1 Websocket no longer traps exits by default. * *2.8*: The `active_n` option was added. * *2.7*: The commands based interface has been documented. diff --git a/ebin/cowboy.app b/ebin/cowboy.app index 9f3e1cbc..b5932d9f 100644 --- a/ebin/cowboy.app +++ b/ebin/cowboy.app @@ -1,7 +1,7 @@ {application, 'cowboy', [ {description, "Small, fast, modern HTTP server."}, - {vsn, "2.10.0"}, - {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_loop','cowboy_metrics_h','cowboy_middleware','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']}, + {vsn, "2.12.0"}, + {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']}, {registered, [cowboy_sup,cowboy_clock]}, {applications, [kernel,stdlib,crypto,cowlib,ranch]}, {optional_applications, []}, diff --git a/erlang.mk b/erlang.mk index 93c536ae..6c58ea8c 100644 --- a/erlang.mk +++ b/erlang.mk @@ -17,7 +17,7 @@ ERLANG_MK_FILENAME := $(realpath $(lastword $(MAKEFILE_LIST))) export ERLANG_MK_FILENAME -ERLANG_MK_VERSION = bb811a8 +ERLANG_MK_VERSION = 16d60fa ERLANG_MK_WITHOUT = # Make 3.81 and 3.82 are deprecated. @@ -3565,7 +3565,7 @@ REBAR_DEPS_DIR = $(DEPS_DIR) export REBAR_DEPS_DIR REBAR3_GIT ?= https://github.com/erlang/rebar3 -REBAR3_COMMIT ?= 3f563feaf1091a1980241adefa83a32dd2eebf7c # 3.20.0 +REBAR3_COMMIT ?= 06aaecd51b0ce828b66bb65a74d3c1fd7833a4ba # 3.22.1 + OTP-27 fixes CACHE_DEPS ?= 0 @@ -4665,7 +4665,6 @@ define makedep.erl end, MakeDepend = fun (F, Fd, Mod, StartLocation) -> - {ok, Filename} = file:pid2name(Fd), case io:parse_erl_form(Fd, undefined, StartLocation) of {ok, AbsData, EndLocation} -> case AbsData of diff --git a/examples/ssl_hello_world/README.asciidoc b/examples/ssl_hello_world/README.asciidoc index 70ee7f8c..feaa60c6 100644 --- a/examples/ssl_hello_world/README.asciidoc +++ b/examples/ssl_hello_world/README.asciidoc @@ -9,8 +9,9 @@ $ make run Then point your browser to https://localhost:8443 -You will need to temporarily trust the root certificate authority, -which can also be found in `priv/ssl/cowboy-ca.crt`. +You will be greeted by a security message. You can ask for more +information and ultimately accept to access localhost. This is +due to the example using a self-signed certificate. Recent browsers will communicate using HTTP/2. Older browsers will use HTTP/1.1. @@ -19,7 +20,7 @@ will use HTTP/1.1. [source,bash] ---- -$ curl --cacert priv/ssl/cowboy-ca.crt -i https://localhost:8443 +$ curl -k -i https://localhost:8443 HTTP/1.1 200 OK connection: keep-alive server: Cowboy diff --git a/examples/ssl_hello_world/priv/ssl/cert.pem b/examples/ssl_hello_world/priv/ssl/cert.pem new file mode 100644 index 00000000..69ed65fb --- /dev/null +++ b/examples/ssl_hello_world/priv/ssl/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAjegAwIBAgIUD7jNyCgABo8GlnEojOSTFWZzkJswDQYJKoZIhvcNAQEL +BQAwNzELMAkGA1UEBhMCRlIxEzARBgNVBAgMClNvbWUtU3RhdGUxEzARBgNVBAoM +Ck5pbmUgTmluZXMwHhcNMjQwMTI2MTQyODExWhcNMzcxMDA0MTQyODExWjA3MQsw +CQYDVQQGEwJGUjETMBEGA1UECAwKU29tZS1TdGF0ZTETMBEGA1UECgwKTmluZSBO +aW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKfNEwF0v1Gm2e6a +M4hqI3JhmerZSNYWw8NiaUybR5hVUS9X4Chk+/y8kBLX2OYbGGlAxgbOZJa5D+kf +H1iakoUQaILinxPx3yxtIOePS3q/Xi5/EBVTdwLOoI26oSdzY2RTKKAPO1PCcAjq +6gDpw2u7q26sSU1kul6dD4Wle6+yNtnJdNKo9zLCLXr6TtuHdvbAU1oblLCKZ1Db +/uLkhGaUI/EUNeU1ZJrPmnoneYkTcG5mC5PMFVhqJ3bNYez5Hgr2Ra1Fz0dVgmRM +FpJ8NF6UQgA9dAs2Oh1uWbTjJiX0tO92RslXlhpLHS2VKZWsxiN2bniNXsNKzQ9M +ty0qnxkCAwEAAaNTMFEwHQYDVR0OBBYEFKuBPzB9rBCJNAnUyQMXjkVKIMJlMB8G +A1UdIwQYMBaAFKuBPzB9rBCJNAnUyQMXjkVKIMJlMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADggEBAHWXDKlY39csROTQ2Dm3CnTj14tj3cW4onsOYTKW +FSlVdMOk3+ionB4vZA/Ino8OjrjiZ2dB3Tvl2J+AxEea3ltDbdh6qVuqSwvQZCeV +8gWp05wzyTfIpQRD10ZwOU6dzR89T+o7oG/7D8Ydk3nzecthF1aU0YBW8OtuZFog +lC/PIIoVEyUiTEnFJrkQge1OmZWiAuImIed+cEmkw9ZAN2/9i/OxWZKAGoKrmfPq +kzdOoxxFRLnqHo2OYdA0IPpSuGK5ayjYrLgXW0Wa4FKzmDh7Gy+JSrvLuFur9PEi +D0Encva2uX1hAcFQDrzICTsD6ANuIbw0cmlrCJYH6E21PrM= +-----END CERTIFICATE----- diff --git a/examples/ssl_hello_world/priv/ssl/cowboy-ca.crt b/examples/ssl_hello_world/priv/ssl/cowboy-ca.crt deleted file mode 100644 index a35ac390..00000000 --- a/examples/ssl_hello_world/priv/ssl/cowboy-ca.crt +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICeDCCAeGgAwIBAgIJAOvpU0y2e5J4MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV -BAYTAlVTMQ4wDAYDVQQIDAVUZXhhczETMBEGA1UECgwKTmluZSBOaW5lczEPMA0G -A1UECwwGQ293Ym95MRAwDgYDVQQDDAdST09UIENBMB4XDTEzMDIyODA1MTAwMVoX -DTMzMDIyMzA1MTAwMVowVTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRMw -EQYDVQQKDApOaW5lIE5pbmVzMQ8wDQYDVQQLDAZDb3dib3kxEDAOBgNVBAMMB1JP -T1QgQ0EwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMzmY7Us06yjyUbpqwPx -Iv+xh/g3V7we07ClC9GEYnvr3OQvdA1jFEHccMBUUjRoQ8DPd6uSyK5UkixABs08 -Tt5B3VsnGKr0DIN+IO4SN2PkmBqIU/BN3KdcwN65YNr3iM0KsKWeFtAZdYx4CakX -7REbO0wjK20AH3xSBn3uFGiBAgMBAAGjUDBOMB0GA1UdDgQWBBRKfZ8KF2jlLBDm -NL6IuEuGY0pdbzAfBgNVHSMEGDAWgBRKfZ8KF2jlLBDmNL6IuEuGY0pdbzAMBgNV -HRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4GBAG1I0kBxXiLkM1b7rl2zPLizREYg -1m+ajb6rWzPOBg6TXjv58Be+H4tqoHIL/M/crixew5emftBkuAGjiKMhbIokjvan -aPTCV8U6HHvNvz9c68HpESWbd+56cHqfsS5XCKp1OpW5tbL2UQYpFKMP4qmbv3Ea -pBfPPmSFMBb1i2AI ------END CERTIFICATE----- diff --git a/examples/ssl_hello_world/priv/ssl/key.pem b/examples/ssl_hello_world/priv/ssl/key.pem new file mode 100644 index 00000000..3f9fbe47 --- /dev/null +++ b/examples/ssl_hello_world/priv/ssl/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCnzRMBdL9Rptnu +mjOIaiNyYZnq2UjWFsPDYmlMm0eYVVEvV+AoZPv8vJAS19jmGxhpQMYGzmSWuQ/p +Hx9YmpKFEGiC4p8T8d8sbSDnj0t6v14ufxAVU3cCzqCNuqEnc2NkUyigDztTwnAI +6uoA6cNru6turElNZLpenQ+FpXuvsjbZyXTSqPcywi16+k7bh3b2wFNaG5SwimdQ +2/7i5IRmlCPxFDXlNWSaz5p6J3mJE3BuZguTzBVYaid2zWHs+R4K9kWtRc9HVYJk +TBaSfDRelEIAPXQLNjodblm04yYl9LTvdkbJV5YaSx0tlSmVrMYjdm54jV7DSs0P +TLctKp8ZAgMBAAECggEAR5e6D6l5hUNcgS4+ZWnvhLo6utYI+vrMfFzNE3e+5LIm +CL6D74gicRMcn0WDj62ozSNrOfUuOpZrwOlb7OhKMkataIZ7G73bG6/V1aYwLIdg +jhL9UDQDt2lkXAPwBQ54rhHC6AOHqvVu6ocb3tbd32W7P2V3gvNChuKZAEr6Chwc +1JE5e1k7uZK4rjqZhd86pV2hks/jNknAZpEROTw80qpo3MzlMDMhXyKmyGa84t91 +1bijJ2DMPKsaxSYkWa06Zx3ymiX+qtKFRnSqZo2aEqpeTgQ0hRBSA429d7uCKO0o +kwqOyT85qMFRA+4jfkcAwUi4DELVCFlN/QNWCMH09wKBgQDVuw/sGnjVxCQ/s7pH +FuGA55S1qUtrcYsMHV5uZNtxLOqeAURomgiTpDVNNhLBuJwVjZrBv8Msl1/99EZ7 +8Hws+ERcjlbmyBiq6/VdRW6bJsrFnOS4qUbwWQp0Yztdeu6sTwIEI0KO/oFypf9G +L9mwjXwTvWEFg5etW1BPq+XmMwKBgQDI/KXNul1zCnrOY6sYrbPShYLZgPQRjNi5 +Ho6N5NxRc3xhyzExbjNtA/N/30d+/p7H8ND+TgpsYdjvEqqgpQQmCeg3/n6eSzb2 +hotCVBt8dU2TjD5v68DLzGv61s7PV81e4grkU5nCe+y7zJMwKGQ8BbmYTBBYEO0P +nTHwuwHhgwKBgQCx2B8OopRro/NZwm69Wq+3+HtIkh98vxUptoJuL6RdzzdG1N0c +gRej6t6jadw/sCLI2HSuxaddQnSQt6Oy29AoB0mzDooHLPdBumgH/Y9ksOnHd57m +fYzWz/CgGjY6ueFCJdgSo1ht7h6+zJvWxlhIzeIx9sJ1uSMMEFCKiwoY+wKBgGb+ +kTjLt/er9yKskJEk8nF/WX58RpZ3xteWgRbVoNFcjPDQX3UlM9U5oR52HP1HHbb4 +ASFQfKbtvW1F84o/BdE4YnfPQrN7d779U3+5+hvdQNPLmnNgLHxDVVJFodU++U8W +Jt66uKChQL88JnEXQcZAaMtSr01x3wmRVHY4Xs5hAoGBAMPfa+rcGukjbMF+MZ0P +ZV1Pq7AxVJ/C0XINnpZrsN+e6dO52Y2VXbnQkML7PKZXzSY88QwunBp88VoPlDux +llmLZc54zUFlsC1iHrEzt+hoxFG0tfL83vic5kSx6u5oZdxjZ2InqTzE8TmORU3v +6/ik7Q4VeDQ5uLnR4GiLW+qj +-----END PRIVATE KEY----- diff --git a/examples/ssl_hello_world/priv/ssl/server.crt b/examples/ssl_hello_world/priv/ssl/server.crt deleted file mode 100644 index 0bdfaed0..00000000 --- a/examples/ssl_hello_world/priv/ssl/server.crt +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICpTCCAg6gAwIBAgIJAOvpU0y2e5J5MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV -BAYTAlVTMQ4wDAYDVQQIDAVUZXhhczETMBEGA1UECgwKTmluZSBOaW5lczEPMA0G -A1UECwwGQ293Ym95MRAwDgYDVQQDDAdST09UIENBMB4XDTEzMDIyODA1MjMzNFoX -DTMzMDIyMzA1MjMzNFowVzELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRMw -EQYDVQQKDApOaW5lIE5pbmVzMQ8wDQYDVQQLDAZDb3dib3kxEjAQBgNVBAMMCWxv -Y2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAzbW1GjECzHUc/WST -qLiAGqjCNccR5saVS+yoz2SPRhpoyf0/qBrX5BY0tzmgozoTiRfE4wCiVD99Cc+D -rp/FM49r4EpZdocIovprmOmv/gwkoj95zaA6PKNn1OdmDp2hwJsX2Zm3kpbGUZTx -jDkkccmgUb4EjL7qNHq7saQtivUCAwEAAaN7MHkwCQYDVR0TBAIwADAsBglghkgB -hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE -FB6jTEIWI8T1ckORA4GezbyYxtbvMB8GA1UdIwQYMBaAFEp9nwoXaOUsEOY0voi4 -S4ZjSl1vMA0GCSqGSIb3DQEBBQUAA4GBACMboVQjrx8u/fk3gl/sR0tbA0Wf/NcS -2Dzsy2czndgVUAG4Sqb+hfgn0dqAyUKghRrj3JDcYxYksGPIklDfPzZb7yJ39l16 -6x5ZiIzhp8CAVdPvRxRznw5rZwaXesryXu1jVSZxTr3MYZdkG6KaAM0t90+YlGLZ -UG8fAicx0Bf+ ------END CERTIFICATE----- diff --git a/examples/ssl_hello_world/priv/ssl/server.key b/examples/ssl_hello_world/priv/ssl/server.key deleted file mode 100644 index b6f73742..00000000 --- a/examples/ssl_hello_world/priv/ssl/server.key +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDNtbUaMQLMdRz9ZJOouIAaqMI1xxHmxpVL7KjPZI9GGmjJ/T+o -GtfkFjS3OaCjOhOJF8TjAKJUP30Jz4Oun8Uzj2vgSll2hwii+muY6a/+DCSiP3nN -oDo8o2fU52YOnaHAmxfZmbeSlsZRlPGMOSRxyaBRvgSMvuo0eruxpC2K9QIDAQAB -AoGAaD85c/h6bpq7Aj7CBbLaWKhFI3OqwsTITB22vsM7SE+B4zsP02UnG1OVi3UM -zytTUxpUkKV1njQ+bYZYOVqGWF4Up8tTqUglHn0FTPok1AIemELWtz3sXvdSHC1T -lqvFBAZ9kibn13qGyVOiyCFaMwfOM/05RvV7p3jfUMTWnNECQQDs7yCJZ8Ol8MyH -TGZzvkjoN2zg1KwmTbSD1hkP6QAJtPdRuqFbjlEru0/PefgOXsWLRIa3/3v0qw2G -xGkV6AXTAkEA3kNbFisqUydjPnZIYv/P6SvPdUimHJEjXbAbfNfzS9dzszrOVJd2 -XqGH7z5yzjoH3IyaIMW8GnubVzGDSjrHFwJAKSU5vELlygpwKkrNO+pelN0TLlQg -dSJnZ8GlZorq88SWcn37iX/EftivenNO7YftvEqxLoDSkOGnnrC7Iw/A+wJBAIEe -L/QY72WPJCBNJpAce/PA96vyoE1II3txqwZDjZspdpVQPDz4IFOpEwbxCFC1dYuy -Qnd3Z2cbF4r3wIWGz9ECQQCJGNhUNtY+Om1ELdqPcquxE2VRV/pucnvJSTKwyo2C -Rvm6H7kFDwPDuN23YnTOlTiho0zzCkclcIukhIVJ+dKz ------END RSA PRIVATE KEY----- diff --git a/examples/ssl_hello_world/src/ssl_hello_world_app.erl b/examples/ssl_hello_world/src/ssl_hello_world_app.erl index 959dc779..542e4d81 100644 --- a/examples/ssl_hello_world/src/ssl_hello_world_app.erl +++ b/examples/ssl_hello_world/src/ssl_hello_world_app.erl @@ -19,9 +19,8 @@ start(_Type, _Args) -> PrivDir = code:priv_dir(ssl_hello_world), {ok, _} = cowboy:start_tls(https, [ {port, 8443}, - {cacertfile, PrivDir ++ "/ssl/cowboy-ca.crt"}, - {certfile, PrivDir ++ "/ssl/server.crt"}, - {keyfile, PrivDir ++ "/ssl/server.key"} + {certfile, PrivDir ++ "/ssl/cert.pem"}, + {keyfile, PrivDir ++ "/ssl/key.pem"} ], #{env => #{dispatch => Dispatch}}), ssl_hello_world_sup:start_link(). diff --git a/rebar.config b/rebar.config index 08bb1eca..c22692c4 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,4 @@ {deps, [ -{cowlib,".*",{git,"https://github.com/ninenines/cowlib","2.12.1"}},{ranch,".*",{git,"https://github.com/ninenines/ranch","1.8.0"}} +{cowlib,".*",{git,"https://github.com/ninenines/cowlib","master"}},{ranch,".*",{git,"https://github.com/ninenines/ranch","1.8.0"}} ]}. {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 21d7f597..e5ed831f 100644 --- a/src/cowboy.erl +++ b/src/cowboy.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -16,6 +16,7 @@ -export([start_clear/3]). -export([start_tls/3]). +-export([start_quic/3]). -export([stop_listener/1]). -export([get_env/2]). -export([get_env/3]). @@ -25,6 +26,9 @@ -export([log/2]). -export([log/4]). +%% Don't warn about the bad quicer specs. +-dialyzer([{nowarn_function, start_quic/3}]). + -type opts() :: cowboy_http:opts() | cowboy_http2:opts(). -export_type([opts/0]). @@ -44,6 +48,7 @@ -spec start_clear(ranch:ref(), ranch:opts(), opts()) -> {ok, pid()} | {error, any()}. + start_clear(Ref, TransOpts0, ProtoOpts0) -> TransOpts1 = ranch:normalize_opts(TransOpts0), {TransOpts, ConnectionType} = ensure_connection_type(TransOpts1), @@ -52,6 +57,7 @@ start_clear(Ref, TransOpts0, ProtoOpts0) -> -spec start_tls(ranch:ref(), ranch:opts(), opts()) -> {ok, pid()} | {error, any()}. + start_tls(Ref, TransOpts0, ProtoOpts0) -> TransOpts1 = ranch:normalize_opts(TransOpts0), SocketOpts = maps:get(socket_opts, TransOpts1, []), @@ -62,28 +68,103 @@ start_tls(Ref, TransOpts0, ProtoOpts0) -> ProtoOpts = ProtoOpts0#{connection_type => ConnectionType}, ranch:start_listener(Ref, ranch_ssl, TransOpts, cowboy_tls, ProtoOpts). +%% @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. +%% +%% @todo Better type for transport options. Might require fixing quicer types. + +-spec start_quic(ranch:ref(), #{socket_opts => [{atom(), _}]}, cowboy_http3:opts()) + -> {ok, pid()}. + +start_quic(Ref, TransOpts, ProtoOpts) -> + {ok, _} = application:ensure_all_started(quicer), + Parent = self(), + SocketOpts0 = maps:get(socket_opts, TransOpts, []), + {Port, SocketOpts2} = case lists:keytake(port, 1, SocketOpts0) of + {value, {port, Port0}, SocketOpts1} -> + {Port0, SocketOpts1}; + false -> + {port_0(), SocketOpts0} + end, + SocketOpts = [ + {alpn, ["h3"]}, %% @todo Why not binary? + {peer_unidi_stream_count, 3}, %% We only need control and QPACK enc/dec. + {peer_bidi_stream_count, 100} + |SocketOpts2], + _ListenerPid = spawn(fun() -> + {ok, Listener} = quicer:listen(Port, SocketOpts), + Parent ! {ok, Listener}, + _AcceptorPid = [spawn(fun AcceptLoop() -> + {ok, Conn} = quicer:accept(Listener, []), + Pid = spawn(fun() -> + receive go -> ok end, + %% We have to do the handshake after handing control of + %% the connection otherwise streams may come in before + %% the controlling process is changed and messages will + %% not be sent to the correct process. + {ok, Conn} = quicer:handshake(Conn), + process_flag(trap_exit, true), %% @todo Only if supervisor though. + try cowboy_http3:init(Parent, Ref, Conn, ProtoOpts) + catch + exit:{shutdown,_} -> ok; + C:E:S -> log(error, "CRASH ~p:~p:~p", [C,E,S], ProtoOpts) + end + end), + ok = quicer:controlling_process(Conn, Pid), + Pid ! go, + AcceptLoop() + end) || _ <- lists:seq(1, 20)], + %% Listener process must not terminate. + receive after infinity -> ok end + end), + receive + {ok, Listener} -> + {ok, Listener} + end. + +%% Select a random UDP port using gen_udp because quicer +%% does not provide equivalent functionality. Taken from +%% quicer test suites. +port_0() -> + {ok, Socket} = gen_udp:open(0, [{reuseaddr, true}]), + {ok, {_, Port}} = inet:sockname(Socket), + gen_udp:close(Socket), + case os:type() of + {unix, darwin} -> + %% Apparently macOS doesn't free the port immediately. + timer:sleep(500); + _ -> + ok + end, + Port. + ensure_connection_type(TransOpts=#{connection_type := ConnectionType}) -> {TransOpts, ConnectionType}; ensure_connection_type(TransOpts) -> {TransOpts#{connection_type => supervisor}, supervisor}. -spec stop_listener(ranch:ref()) -> ok | {error, not_found}. + stop_listener(Ref) -> ranch:stop_listener(Ref). -spec get_env(ranch:ref(), atom()) -> ok. + get_env(Ref, Name) -> Opts = ranch:get_protocol_options(Ref), Env = maps:get(env, Opts, #{}), maps:get(Name, Env). -spec get_env(ranch:ref(), atom(), any()) -> ok. + get_env(Ref, Name, Default) -> Opts = ranch:get_protocol_options(Ref), Env = maps:get(env, Opts, #{}), maps:get(Name, Env, Default). -spec set_env(ranch:ref(), atom(), any()) -> ok. + set_env(Ref, Name, Value) -> Opts = ranch:get_protocol_options(Ref), Env = maps:get(env, Opts, #{}), @@ -93,10 +174,12 @@ set_env(Ref, Name, Value) -> %% Internal. -spec log({log, logger:level(), io:format(), list()}, opts()) -> ok. + log({log, Level, Format, Args}, Opts) -> log(Level, Format, Args, Opts). -spec log(logger:level(), io:format(), list(), opts()) -> ok. + log(Level, Format, Args, #{logger := Logger}) when Logger =/= error_logger -> _ = Logger:Level(Format, Args), diff --git a/src/cowboy_app.erl b/src/cowboy_app.erl index 74cba415..95ae5648 100644 --- a/src/cowboy_app.erl +++ b/src/cowboy_app.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_bstr.erl b/src/cowboy_bstr.erl index d8041e49..f23167d0 100644 --- a/src/cowboy_bstr.erl +++ b/src/cowboy_bstr.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_children.erl b/src/cowboy_children.erl index 05d39fb8..305c9897 100644 --- a/src/cowboy_children.erl +++ b/src/cowboy_children.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_clear.erl b/src/cowboy_clear.erl index e4ca9da2..eaeab745 100644 --- a/src/cowboy_clear.erl +++ b/src/cowboy_clear.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_clock.erl b/src/cowboy_clock.erl index 28f8a1b2..e2cdf62d 100644 --- a/src/cowboy_clock.erl +++ b/src/cowboy_clock.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_compress_h.erl b/src/cowboy_compress_h.erl index 57d3dab9..338ea9f5 100644 --- a/src/cowboy_compress_h.erl +++ b/src/cowboy_compress_h.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_constraints.erl b/src/cowboy_constraints.erl index 6509c4b2..33f0111f 100644 --- a/src/cowboy_constraints.erl +++ b/src/cowboy_constraints.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin +%% Copyright (c) 2014-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_handler.erl b/src/cowboy_handler.erl index c0f7ff71..50481681 100644 --- a/src/cowboy_handler.erl +++ b/src/cowboy_handler.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_http.erl b/src/cowboy_http.erl index 7aa799fd..9c92ec5a 100644 --- a/src/cowboy_http.erl +++ b/src/cowboy_http.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -12,6 +12,8 @@ %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +%% @todo Worth renaming to cowboy_http1. +%% @todo Change use of cow_http to cow_http1 where appropriate. -module(cowboy_http). -export([init/6]). @@ -1531,7 +1533,7 @@ maybe_socket_error(_, Result = {ok, _}, _) -> maybe_socket_error(State, {error, Reason}, Human) -> terminate(State, {socket_error, Reason, Human}). --spec terminate(_, _) -> no_return(). +-spec terminate(#state{} | undefined, _) -> no_return(). terminate(undefined, Reason) -> exit({shutdown, Reason}); terminate(State=#state{streams=Streams, children=Children}, Reason) -> diff --git a/src/cowboy_http2.erl b/src/cowboy_http2.erl index cd685451..2e73d5f8 100644 --- a/src/cowboy_http2.erl +++ b/src/cowboy_http2.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin +%% Copyright (c) 2015-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -44,6 +44,7 @@ max_connection_window_size => 0..16#7fffffff, max_decode_table_size => non_neg_integer(), max_encode_table_size => non_neg_integer(), + max_fragmented_header_block_size => 16384..16#7fffffff, max_frame_size_received => 16384..16777215, max_frame_size_sent => 16384..16777215 | infinity, max_received_frame_rate => {pos_integer(), timeout()}, @@ -1138,7 +1139,7 @@ maybe_socket_error(_, Result = {ok, _}, _) -> maybe_socket_error(State, {error, Reason}, Human) -> terminate(State, {socket_error, Reason, Human}). --spec terminate(#state{}, _) -> no_return(). +-spec terminate(#state{} | undefined, _) -> no_return(). terminate(undefined, Reason) -> exit({shutdown, Reason}); terminate(State=#state{socket=Socket, transport=Transport, http2_status=Status, diff --git a/src/cowboy_http3.erl b/src/cowboy_http3.erl new file mode 100644 index 00000000..ef3e3f6b --- /dev/null +++ b/src/cowboy_http3.erl @@ -0,0 +1,973 @@ +%% Copyright (c) 2023-2024, Loïc Hoguin +%% +%% 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. + +%% A key difference between cowboy_http2 and cowboy_http3 +%% is that HTTP/3 streams are QUIC streams and therefore +%% much of the connection state is handled outside of +%% Cowboy. + +-module(cowboy_http3). + +-export([init/4]). + +%% Temporary callback to do sendfile over QUIC. +-export([send/2]). + +%% @todo Graceful shutdown? Linger? Timeouts? Frame rates? PROXY header? +-type opts() :: #{ + compress_buffering => boolean(), + compress_threshold => non_neg_integer(), + connection_type => worker | supervisor, + enable_connect_protocol => boolean(), + env => cowboy_middleware:env(), + logger => module(), + max_decode_blocked_streams => 0..16#3fffffffffffffff, + max_decode_table_size => 0..16#3fffffffffffffff, + max_encode_blocked_streams => 0..16#3fffffffffffffff, + max_encode_table_size => 0..16#3fffffffffffffff, + max_ignored_frame_size_received => non_neg_integer() | infinity, + metrics_callback => cowboy_metrics_h:metrics_callback(), + metrics_req_filter => fun((cowboy_req:req()) -> map()), + metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()), + middlewares => [module()], + shutdown_timeout => timeout(), + stream_handlers => [module()], + tracer_callback => cowboy_tracer_h:tracer_callback(), + tracer_flags => [atom()], + tracer_match_specs => cowboy_tracer_h:tracer_match_specs(), + %% Open ended because configured stream handlers might add options. + _ => _ +}. +-export_type([opts/0]). + +-record(stream, { + id :: cow_http3:stream_id(), + + %% Whether the stream is currently in a special state. + status :: header | {unidi, control | encoder | decoder} + | normal | {data | ignore, non_neg_integer()} | stopping, + + %% Stream buffer. + buffer = <<>> :: binary(), + + %% Stream state. + state = undefined :: undefined | {module, any()} +}). + +-record(state, { + parent :: pid(), + ref :: ranch:ref(), + conn :: cowboy_quicer:quicer_connection_handle(), + opts = #{} :: opts(), + + %% Remote address and port for the connection. + peer = undefined :: {inet:ip_address(), inet:port_number()}, + + %% Local address and port for the connection. + sock = undefined :: {inet:ip_address(), inet:port_number()}, + + %% Client certificate. + cert :: undefined | binary(), + + %% HTTP/3 state machine. + http3_machine :: cow_http3_machine:http3_machine(), + + %% Specially handled local unidi streams. + local_control_id = undefined :: undefined | cow_http3:stream_id(), + local_encoder_id = undefined :: undefined | cow_http3:stream_id(), + local_decoder_id = undefined :: undefined | cow_http3:stream_id(), + + %% Bidirectional streams used for requests and responses, + %% as well as unidirectional streams initiated by the client. + streams = #{} :: #{cow_http3:stream_id() => #stream{}}, + + %% Lingering streams that were recently reset. We may receive + %% pending data or messages for these streams a short while + %% after they have been reset. + lingering_streams = [] :: [non_neg_integer()], + + %% 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() +}). + +-spec init(pid(), ranch:ref(), cowboy_quicer:quicer_connection_handle(), opts()) + -> no_return(). + +init(Parent, Ref, Conn, Opts) -> + {ok, SettingsBin, HTTP3Machine0} = cow_http3_machine:init(server, Opts), + %% Immediately open a control, encoder and decoder stream. + %% @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]), + 'A socket error occurred when opening the control stream.'), + {ok, EncoderID} = maybe_socket_error(undefined, + cowboy_quicer: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>>), + '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), + 'A socket error occurred when retrieving the peer name.'), + {ok, Sock} = maybe_socket_error(undefined, cowboy_quicer:sockname(Conn), + 'A socket error occurred when retrieving the sock name.'), + CertResult = case cowboy_quicer:peercert(Conn) of + {error, no_peercert} -> + {ok, undefined}; + Cert0 -> + Cert0 + 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, + opts=Opts, peer=Peer, sock=Sock, cert=Cert, + http3_machine=HTTP3Machine, local_control_id=ControlID, + local_encoder_id=EncoderID, local_decoder_id=DecoderID}). + +loop(State0=#state{opts=Opts, children=Children}) -> + receive + Msg when element(1, Msg) =:= quic -> + handle_quic_msg(State0, Msg); + %% Timeouts. + {timeout, Ref, {shutdown, Pid}} -> + cowboy_children:shutdown_timeout(Children, Ref, Pid), + loop(State0); + %% Messages pertaining to a stream. + {{Pid, StreamID}, Msg} when Pid =:= self() -> + loop(info(State0, StreamID, Msg)); + %% Exit signal from children. + Msg = {'EXIT', Pid, _} -> + loop(down(State0, Pid, Msg)); + Msg -> + cowboy:log(warning, "Received stray message ~p.", [Msg], Opts), + loop(State0) + end. + +handle_quic_msg(State0=#state{opts=Opts}, Msg) -> + case cowboy_quicer:handle(Msg) of + {data, StreamID, IsFin, Data} -> + parse(State0, StreamID, Data, IsFin); + {stream_started, StreamID, StreamType} -> + State = stream_new_remote(State0, StreamID, StreamType), + loop(State); + {stream_closed, StreamID, ErrorCode} -> + State = stream_closed(State0, StreamID, ErrorCode), + loop(State); + closed -> + %% @todo Different error reason if graceful? + Reason = {socket_error, closed, 'The socket has been closed.'}, + terminate(State0, Reason); + ok -> + 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.'}) + end. + +parse(State=#state{opts=Opts}, StreamID, Data, IsFin) -> + case stream_get(State, StreamID) of + Stream=#stream{buffer= <<>>} -> + parse1(State, Stream, Data, IsFin); + Stream=#stream{buffer=Buffer} -> + Stream1 = Stream#stream{buffer= <<>>}, + parse1(stream_store(State, Stream1), + Stream1, <>, IsFin); + %% Pending data for a stream that has been reset. Ignore. + error -> + case is_lingering_stream(State, StreamID) of + true -> + ok; + 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) + end. + +parse1(State, Stream=#stream{status=header}, Data, IsFin) -> + parse_unidirectional_stream_header(State, Stream, Data, IsFin); +parse1(State=#state{http3_machine=HTTP3Machine0}, + #stream{status={unidi, Type}, id=StreamID}, Data, IsFin) + when Type =:= encoder; Type =:= decoder -> + case cow_http3_machine:unidi_data(Data, IsFin, StreamID, HTTP3Machine0) of + {ok, Instrs, HTTP3Machine} -> + loop(send_instructions(State#state{http3_machine=HTTP3Machine}, Instrs)); + {error, Error={connection_error, _, _}, HTTP3Machine} -> + terminate(State#state{http3_machine=HTTP3Machine}, Error) + end; +parse1(State, Stream=#stream{status={data, Len}, id=StreamID}, Data, IsFin) -> + DataLen = byte_size(Data), + if + DataLen < Len -> + %% We don't have the full frame but this is the end of the + %% data we have. So FrameIsFin is equivalent to IsFin here. + loop(frame(State, Stream#stream{status={data, Len - DataLen}}, {data, Data}, IsFin)); + true -> + <> = Data, + FrameIsFin = is_fin(IsFin, Rest), + parse(frame(State, Stream#stream{status=normal}, {data, Data1}, FrameIsFin), + StreamID, Rest, IsFin) + end; +parse1(State, Stream=#stream{status={ignore, Len}, id=StreamID}, Data, IsFin) -> + DataLen = byte_size(Data), + if + DataLen < Len -> + loop(stream_store(State, Stream#stream{status={ignore, Len - DataLen}})); + true -> + <<_:Len/binary, Rest/bits>> = Data, + parse(stream_store(State, Stream#stream{status=normal}), + StreamID, Rest, IsFin) + end; +%% @todo Clause that discards receiving data for stopping streams. +%% We may receive a few more frames after we abort receiving. +parse1(State=#state{opts=Opts}, Stream=#stream{id=StreamID}, Data, IsFin) -> + case cow_http3:parse(Data) of + {ok, Frame, Rest} -> + FrameIsFin = is_fin(IsFin, Rest), + parse(frame(State, Stream, Frame, FrameIsFin), StreamID, Rest, IsFin); + {more, Frame = {data, _}, Len} -> + %% We're at the end of the data so FrameIsFin is equivalent to IsFin. + case IsFin of + nofin -> + %% The stream will be stored at the end of processing commands. + loop(frame(State, Stream#stream{status={data, Len}}, Frame, nofin)); + fin -> + terminate(State, {connection_error, h3_frame_error, + 'Last frame on stream was truncated. (RFC9114 7.1)'}) + end; + {more, ignore, Len} -> + %% @todo This setting should be tested. + %% + %% While the default value doesn't warrant doing a streaming ignore + %% (and could work just fine with the 'more' clause), this value + %% is configurable and users may want to set it large. + MaxIgnoredLen = maps:get(max_ignored_frame_size_received, Opts, 16384), + %% We're at the end of the data so FrameIsFin is equivalent to IsFin. + case IsFin of + nofin when Len < MaxIgnoredLen -> + %% We are not processing commands so we must store the stream. + %% We also call ignored_frame here; we will not need to call + %% it again when ignoring the rest of the data. + Stream1 = Stream#stream{status={ignore, Len}}, + State1 = ignored_frame(State, Stream1), + loop(stream_store(State1, Stream1)); + nofin -> + terminate(State, {connection_error, h3_excessive_load, + 'Ignored frame larger than limit. (RFC9114 10.5)'}); + fin -> + terminate(State, {connection_error, h3_frame_error, + 'Last frame on stream was truncated. (RFC9114 7.1)'}) + end; + {ignore, Rest} -> + parse(ignored_frame(State, Stream), StreamID, Rest, IsFin); + Error = {connection_error, _, _} -> + terminate(State, Error); + more when Data =:= <<>> -> + %% The buffer was already reset to <<>>. + loop(stream_store(State, Stream)); + more -> + %% We're at the end of the data so FrameIsFin is equivalent to IsFin. + case IsFin of + nofin -> + loop(stream_store(State, Stream#stream{buffer=Data})); + fin -> + terminate(State, {connection_error, h3_frame_error, + 'Last frame on stream was truncated. (RFC9114 7.1)'}) + end + end. + +%% We may receive multiple frames in a single QUIC packet. +%% The FIN flag applies to the QUIC packet, not to the frame. +%% We must therefore only consider the frame to have a FIN +%% flag if there's no data remaining to be read. +is_fin(fin, <<>>) -> fin; +is_fin(_, _) -> nofin. + +parse_unidirectional_stream_header(State0=#state{http3_machine=HTTP3Machine0}, + Stream0=#stream{id=StreamID}, Data, IsFin) -> + case cow_http3:parse_unidi_stream_header(Data) of + {ok, Type, Rest} when Type =:= control; Type =:= encoder; Type =:= decoder -> + case cow_http3_machine:set_unidi_remote_stream_type( + StreamID, Type, HTTP3Machine0) of + {ok, HTTP3Machine} -> + State = State0#state{http3_machine=HTTP3Machine}, + Stream = Stream0#stream{status={unidi, Type}}, + parse(stream_store(State, Stream), StreamID, Rest, IsFin); + {error, Error={connection_error, _, _}, HTTP3Machine} -> + terminate(State0#state{http3_machine=HTTP3Machine}, Error) + end; + {ok, push, _} -> + terminate(State0, {connection_error, h3_stream_creation_error, + 'Only servers can push. (RFC9114 6.2.2)'}); + %% Unknown stream types must be ignored. We choose to abort the + %% stream instead of reading and discarding the incoming data. + {undefined, _} -> + loop(stream_abort_receive(State0, Stream0, h3_stream_creation_error)) + end. + +frame(State=#state{http3_machine=HTTP3Machine0}, + Stream=#stream{id=StreamID}, Frame, IsFin) -> + case cow_http3_machine:frame(Frame, IsFin, StreamID, HTTP3Machine0) of + {ok, HTTP3Machine} -> + State#state{http3_machine=HTTP3Machine}; + {ok, {data, Data}, HTTP3Machine} -> + data_frame(State#state{http3_machine=HTTP3Machine}, Stream, IsFin, Data); + {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, GoAway={goaway, _}, HTTP3Machine} -> + goaway(State#state{http3_machine=HTTP3Machine}, GoAway); + {error, Error={stream_error, _Reason, _Human}, Instrs, HTTP3Machine} -> + State1 = send_instructions(State#state{http3_machine=HTTP3Machine}, Instrs), + reset_stream(State1, Stream, Error); + {error, Error={connection_error, _, _}, HTTP3Machine} -> + terminate(State#state{http3_machine=HTTP3Machine}, Error) + end. + +data_frame(State=#state{opts=Opts}, + Stream=#stream{id=StreamID, state=StreamState0}, IsFin, Data) -> + try cowboy_stream:data(StreamID, IsFin, Data, StreamState0) of + {Commands, StreamState} -> + commands(State, Stream#stream{state=StreamState}, Commands) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(data, + [StreamID, IsFin, Data, StreamState0], + Class, Exception, Stacktrace), Opts), + reset_stream(State, Stream, {internal_error, {Class, Exception}, + 'Unhandled exception in cowboy_stream:data/4.'}) + end. + +headers_frame(State, Stream, IsFin, Headers, + PseudoHeaders=#{method := <<"CONNECT">>}, _) + when map_size(PseudoHeaders) =:= 2 -> + early_error(State, Stream, IsFin, Headers, PseudoHeaders, 501, + 'The CONNECT method is currently not implemented. (RFC7231 4.3.6)'); +headers_frame(State, Stream, IsFin, Headers, + PseudoHeaders=#{method := <<"TRACE">>}, _) -> + early_error(State, Stream, IsFin, Headers, PseudoHeaders, 501, + 'The TRACE method is currently not implemented. (RFC9114 4.4, RFC7231 4.3.8)'); +headers_frame(State, Stream, IsFin, Headers, PseudoHeaders=#{authority := Authority}, BodyLen) -> + headers_frame_parse_host(State, Stream, IsFin, Headers, PseudoHeaders, BodyLen, Authority); +headers_frame(State, Stream, IsFin, Headers, PseudoHeaders, BodyLen) -> + case lists:keyfind(<<"host">>, 1, Headers) of + {_, Authority} -> + headers_frame_parse_host(State, Stream, IsFin, Headers, PseudoHeaders, BodyLen, Authority); + _ -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'Requests translated from HTTP/1.1 must include a host header. (RFC7540 8.1.2.3, RFC7230 5.4)'}) + end. + +headers_frame_parse_host(State=#state{ref=Ref, peer=Peer, sock=Sock, cert=Cert}, + Stream=#stream{id=StreamID}, IsFin, Headers, + PseudoHeaders=#{method := Method, scheme := Scheme, path := PathWithQs}, + BodyLen, Authority) -> + try cow_http_hd:parse_host(Authority) of + {Host, Port0} -> + Port = ensure_port(Scheme, Port0), + try cow_http:parse_fullpath(PathWithQs) of + {<<>>, _} -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'The path component must not be empty. (RFC7540 8.1.2.3)'}); + {Path, Qs} -> + Req0 = #{ + ref => Ref, + pid => self(), + streamid => StreamID, + peer => Peer, + sock => Sock, + cert => Cert, + method => Method, + scheme => Scheme, + host => Host, + port => Port, + path => Path, + qs => Qs, + version => 'HTTP/3', + headers => headers_to_map(Headers, #{}), + has_body => IsFin =:= nofin, + body_length => BodyLen + }, + %% We add the protocol information for extended CONNECTs. + Req = case PseudoHeaders of + #{protocol := Protocol} -> Req0#{protocol => Protocol}; + _ -> Req0 + end, + headers_frame(State, Stream, Req) + catch _:_ -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'The :path pseudo-header is invalid. (RFC7540 8.1.2.3)'}) + end + catch _:_ -> + reset_stream(State, Stream, {stream_error, h3_message_error, + 'The :authority pseudo-header is invalid. (RFC7540 8.1.2.3)'}) + end. + +%% @todo Copied from cowboy_http2. +%% @todo How to handle "http"? +ensure_port(<<"http">>, undefined) -> 80; +ensure_port(<<"https">>, undefined) -> 443; +ensure_port(_, Port) -> Port. + +%% @todo Copied from cowboy_http2. +%% This function is necessary to properly handle duplicate headers +%% and the special-case cookie header. +headers_to_map([], Acc) -> + Acc; +headers_to_map([{Name, Value}|Tail], Acc0) -> + Acc = case Acc0 of + %% The cookie header does not use proper HTTP header lists. + #{Name := Value0} when Name =:= <<"cookie">> -> + Acc0#{Name => << Value0/binary, "; ", Value/binary >>}; + #{Name := Value0} -> + Acc0#{Name => << Value0/binary, ", ", Value/binary >>}; + _ -> + Acc0#{Name => Value} + end, + headers_to_map(Tail, Acc). + +headers_frame(State=#state{opts=Opts}, Stream=#stream{id=StreamID}, Req) -> + try cowboy_stream:init(StreamID, Req, Opts) of + {Commands, StreamState} -> + commands(State, Stream#stream{state=StreamState}, Commands) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(init, + [StreamID, Req, Opts], + Class, Exception, Stacktrace), Opts), + reset_stream(State, Stream, {internal_error, {Class, Exception}, + 'Unhandled exception in cowboy_stream:init/3.'}) + end. + +early_error(State0=#state{ref=Ref, opts=Opts, peer=Peer}, + Stream=#stream{id=StreamID}, _IsFin, Headers, #{method := Method}, + StatusCode0, HumanReadable) -> + %% We automatically terminate the stream but it is not an error + %% per se (at least not in the first implementation). + Reason = {stream_error, h3_no_error, HumanReadable}, + %% The partial Req is minimal for now. We only have one case + %% where it can be called (when a method is completely disabled). + PartialReq = #{ + ref => Ref, + peer => Peer, + method => Method, + headers => headers_to_map(Headers, #{}) + }, + Resp = {response, StatusCode0, RespHeaders0=#{<<"content-length">> => <<"0">>}, <<>>}, + try cowboy_stream:early_error(StreamID, Reason, PartialReq, Resp, Opts) of + {response, StatusCode, RespHeaders, RespBody} -> + send_response(State0, Stream, StatusCode, RespHeaders, RespBody) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(early_error, + [StreamID, Reason, PartialReq, Resp, Opts], + Class, Exception, Stacktrace), Opts), + %% We still need to send an error response, so send what we initially + %% wanted to send. It's better than nothing. + send_headers(State0, Stream, fin, StatusCode0, RespHeaders0) + end. + +%% Erlang messages. + +down(State0=#state{opts=Opts, children=Children0}, Pid, Msg) -> + State = case cowboy_children:down(Children0, Pid) of + %% The stream was terminated already. + {ok, undefined, Children} -> + State0#state{children=Children}; + %% The stream is still running. + {ok, StreamID, Children} -> + info(State0#state{children=Children}, StreamID, Msg); + %% The process was unknown. + error -> + cowboy:log(warning, "Received EXIT signal ~p for unknown process ~p.~n", + [Msg, Pid], Opts), + State0 + end, + if +%% @todo +% State#state.http2_status =:= closing, State#state.streams =:= #{} -> +% terminate(State, {stop, normal, 'The connection is going away.'}); + true -> + State + end. + +info(State=#state{opts=Opts, http3_machine=_HTTP3Machine}, StreamID, Msg) -> + case stream_get(State, StreamID) of + Stream=#stream{state=StreamState0} -> + try cowboy_stream:info(StreamID, Msg, StreamState0) of + {Commands, StreamState} -> + commands(State, Stream#stream{state=StreamState}, Commands) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(info, + [StreamID, Msg, StreamState0], + Class, Exception, Stacktrace), Opts), + reset_stream(State, Stream, {internal_error, {Class, Exception}, + 'Unhandled exception in cowboy_stream:info/3.'}) + end; + error -> + case is_lingering_stream(State, StreamID) of + true -> + ok; + false -> + cowboy:log(warning, "Received message ~p for unknown stream ~p.", + [Msg, StreamID], Opts) + end, + State + end. + +%% Stream handler commands. + +commands(State, Stream, []) -> + stream_store(State, Stream); +%% Error responses are sent only if a response wasn't sent already. +commands(State=#state{http3_machine=HTTP3Machine}, Stream=#stream{id=StreamID}, + [{error_response, StatusCode, Headers, Body}|Tail]) -> + case cow_http3_machine:get_bidi_stream_local_state(StreamID, HTTP3Machine) of + {ok, idle} -> + commands(State, Stream, [{response, StatusCode, Headers, Body}|Tail]); + _ -> + commands(State, Stream, Tail) + end; +%% Send an informational response. +commands(State0, Stream, [{inform, StatusCode, Headers}|Tail]) -> + State = send_headers(State0, Stream, idle, StatusCode, Headers), + commands(State, Stream, Tail); +%% Send response headers. +commands(State0, Stream, [{response, StatusCode, Headers, Body}|Tail]) -> + State = send_response(State0, Stream, StatusCode, Headers, Body), + commands(State, Stream, Tail); +%% Send response headers. +commands(State0, Stream, [{headers, StatusCode, Headers}|Tail]) -> + State = send_headers(State0, Stream, nofin, StatusCode, Headers), + commands(State, Stream, Tail); +%%% Send a response body chunk. +commands(State0=#state{conn=Conn}, Stream=#stream{id=StreamID}, [{data, IsFin, Data}|Tail]) -> + _ = case Data of + {sendfile, Offset, Bytes, Path} -> + %% Temporary solution to do sendfile over QUIC. + {ok, _} = ranch_transport:sendfile(?MODULE, {Conn, StreamID}, + Path, Offset, Bytes, []), + ok = maybe_socket_error(State0, + cowboy_quicer:send(Conn, StreamID, cow_http3:data(<<>>), IsFin)); + _ -> + ok = maybe_socket_error(State0, + cowboy_quicer:send(Conn, StreamID, cow_http3:data(Data), IsFin)) + end, + State = maybe_send_is_fin(State0, Stream, IsFin), + commands(State, Stream, Tail); +%%% Send trailers. +commands(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, + Stream=#stream{id=StreamID}, [{trailers, Trailers}|Tail]) -> + State = case cow_http3_machine:prepare_trailers( + StreamID, HTTP3Machine0, maps:to_list(Trailers)) of + {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)), + State1; + {no_trailers, HTTP3Machine} -> + ok = maybe_socket_error(State0, + cowboy_quicer:send(Conn, StreamID, cow_http3:data(<<>>), fin)), + State0#state{http3_machine=HTTP3Machine} + end, + commands(State, Stream, Tail); +%% Send a push promise. +%% +%% @todo Responses sent as a result of a push_promise request +%% must not send push_promise frames themselves. +%% +%% @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]) -> +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}, + [{spawn, Pid, Shutdown}|Tail]) -> + commands(State#state{children=cowboy_children:up(Children, Pid, StreamID, Shutdown)}, + Stream, Tail); +%% Error handling. +commands(State, Stream, [Error = {internal_error, _, _}|_Tail]) -> + %% @todo Do we want to run the commands after an internal_error? + %% @todo Do we even allow commands after? + %% @todo Only reset when the stream still exists. + reset_stream(State, Stream, Error); +%% Use a different protocol within the stream (CONNECT :protocol). +%% @todo Make sure we error out when the feature is disabled. +commands(State0, Stream0=#stream{id=StreamID}, + [{switch_protocol, Headers, _Mod, _ModState}|Tail]) -> + State = info(stream_store(State0, Stream0), StreamID, {headers, 200, Headers}), + Stream = stream_get(State, StreamID), + commands(State, Stream, Tail); +%% Set options dynamically. +commands(State, Stream, [{set_options, _Opts}|Tail]) -> + commands(State, Stream, Tail); +commands(State, Stream, [stop|_Tail]) -> + %% @todo Do we want to run the commands after a stop? + %% @todo Do we even allow commands after? + stop_stream(State, Stream); +%% Log event. +commands(State=#state{opts=Opts}, Stream, [Log={log, _, _, _}|Tail]) -> + cowboy:log(Log, Opts), + commands(State, Stream, Tail). + +send_response(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, + Stream=#stream{id=StreamID}, StatusCode, Headers, Body) -> + Size = case Body of + {sendfile, _, Bytes0, _} -> Bytes0; + _ -> iolist_size(Body) + end, + case Size of + 0 -> + State = send_headers(State0, Stream, fin, StatusCode, Headers), + maybe_send_is_fin(State, Stream, fin); + _ -> + %% @todo Add a test for HEAD to make sure we don't send the body when + %% returning {response...} from a stream handler (or {headers...} then {data...}). + {ok, _IsFin, HeaderBlock, Instrs, HTTP3Machine} + = cow_http3_machine:prepare_headers(StreamID, HTTP3Machine0, nofin, + #{status => cow_http:status_to_integer(StatusCode)}, + headers_to_list(Headers)), + State = send_instructions(State0#state{http3_machine=HTTP3Machine}, Instrs), + %% @todo It might be better to do async sends. + _ = case Body of + {sendfile, Offset, Bytes, Path} -> + ok = maybe_socket_error(State, + cowboy_quicer: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)); + _ -> + ok = maybe_socket_error(State, + cowboy_quicer:send(Conn, StreamID, [ + cow_http3:headers(HeaderBlock), + cow_http3:data(Body) + ], fin)) + end, + maybe_send_is_fin(State, Stream, fin) + end. + +maybe_send_is_fin(State=#state{http3_machine=HTTP3Machine0}, + Stream=#stream{id=StreamID}, fin) -> + HTTP3Machine = cow_http3_machine:close_bidi_stream_for_sending(StreamID, HTTP3Machine0), + maybe_terminate_stream(State#state{http3_machine=HTTP3Machine}, Stream); +maybe_send_is_fin(State, _, _) -> + State. + +%% Temporary callback to do sendfile over QUIC. +-spec send({cowboy_quicer:quicer_connection_handle(), cow_http3:stream_id()}, + iodata()) -> ok | {error, any()}. + +send({Conn, StreamID}, IoData) -> + cowboy_quicer:send(Conn, StreamID, cow_http3:data(IoData)). + +send_headers(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, + #stream{id=StreamID}, IsFin0, StatusCode, Headers) -> + {ok, IsFin, HeaderBlock, Instrs, HTTP3Machine} + = cow_http3_machine:prepare_headers(StreamID, HTTP3Machine0, IsFin0, + #{status => cow_http:status_to_integer(StatusCode)}, + 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)), + State. + +%% The set-cookie header is special; we can only send one cookie per header. +headers_to_list(Headers0=#{<<"set-cookie">> := SetCookies}) -> + Headers = maps:to_list(maps:remove(<<"set-cookie">>, Headers0)), + Headers ++ [{<<"set-cookie">>, Value} || Value <- SetCookies]; +headers_to_list(Headers) -> + maps:to_list(Headers). + +%% @todo We would open unidi streams here if we only open on-demand. +%% No instructions. +send_instructions(State, undefined) -> + State; +%% Decoder instructions. +send_instructions(State=#state{conn=Conn, local_decoder_id=DecoderID}, + {decoder_instructions, DecData}) -> + ok = maybe_socket_error(State, + cowboy_quicer: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)), + State. + +reset_stream(State0=#state{conn=Conn, http3_machine=HTTP3Machine0}, + Stream=#stream{id=StreamID}, Error) -> + Reason = case Error of + {internal_error, _, _} -> h3_internal_error; + {stream_error, Reason0, _} -> Reason0 + 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, + both, cow_http3:error_to_code(Reason)), + State1 = case cow_http3_machine:reset_stream(StreamID, HTTP3Machine0) of + {ok, HTTP3Machine} -> + terminate_stream(State0#state{http3_machine=HTTP3Machine}, Stream, Error); + {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. + +stop_stream(State0=#state{http3_machine=HTTP3Machine}, Stream=#stream{id=StreamID}) -> + %% We abort reading when stopping the stream but only + %% if the client was not finished sending data. + %% We mark the stream as 'stopping' either way. + State = case cow_http3_machine:get_bidi_stream_remote_state(StreamID, HTTP3Machine) of + {ok, fin} -> + stream_store(State0, Stream#stream{status=stopping}); + {error, not_found} -> + stream_store(State0, Stream#stream{status=stopping}); + _ -> + stream_abort_receive(State0, Stream, h3_no_error) + end, + %% Then we may need to send a response or terminate it + %% if the stream handler did not do so already. + case cow_http3_machine:get_bidi_stream_local_state(StreamID, HTTP3Machine) of + %% When the stream terminates normally (without resetting the stream) + %% and no response was sent, we need to send a proper response back to the client. + {ok, idle} -> + info(State, StreamID, {response, 204, #{}, <<>>}); + %% When a response was sent but not terminated, we need to close the stream. + %% We send a final DATA frame to complete the stream. + {ok, nofin} -> + info(State, StreamID, {data, fin, <<>>}); + %% When a response was sent fully we can terminate the stream, + %% regardless of the stream being in half-closed or closed state. + _ -> + terminate_stream(State, Stream, normal) + end. + +maybe_terminate_stream(State, Stream=#stream{status=stopping}) -> + terminate_stream(State, Stream, normal); +%% The Stream will be stored in the State at the end of commands processing. +maybe_terminate_stream(State, _) -> + State. + +terminate_stream(State=#state{streams=Streams0, children=Children0}, + #stream{id=StreamID, state=StreamState}, Reason) -> + Streams = maps:remove(StreamID, Streams0), + terminate_stream_handler(State, StreamID, Reason, StreamState), + Children = cowboy_children:shutdown(Children0, StreamID), + stream_linger(State#state{streams=Streams, children=Children}, StreamID). + +terminate_stream_handler(#state{opts=Opts}, StreamID, Reason, StreamState) -> + try + cowboy_stream:terminate(StreamID, Reason, StreamState) + catch Class:Exception:Stacktrace -> + cowboy:log(cowboy_stream:make_error_log(terminate, + [StreamID, Reason, StreamState], + Class, Exception, Stacktrace), Opts) + end. + +ignored_frame(State=#state{http3_machine=HTTP3Machine0}, #stream{id=StreamID}) -> + case cow_http3_machine:ignored_frame(StreamID, HTTP3Machine0) of + {ok, HTTP3Machine} -> + State#state{http3_machine=HTTP3Machine}; + {error, Error={connection_error, _, _}, HTTP3Machine} -> + terminate(State#state{http3_machine=HTTP3Machine}, Error) + end. + +stream_abort_receive(State=#state{conn=Conn}, Stream=#stream{id=StreamID}, Reason) -> + cowboy_quicer: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. +-spec goaway(#state{}, {goaway, _}) -> no_return(). +goaway(State, {goaway, _}) -> + terminate(State, {stop, goaway, 'The connection is going away.'}). + +%% Function copied from cowboy_http. +maybe_socket_error(State, {error, closed}) -> + terminate(State, {socket_error, closed, 'The socket has been closed.'}); +maybe_socket_error(State, Reason) -> + maybe_socket_error(State, Reason, 'An error has occurred on the socket.'). + +maybe_socket_error(_, Result = ok, _) -> + Result; +maybe_socket_error(_, Result = {ok, _}, _) -> + Result; +maybe_socket_error(State, {error, Reason}, Human) -> + terminate(State, {socket_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, + 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, + 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))), + exit({shutdown, Reason}). + +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; +terminate_all_streams(State, [{StreamID, #stream{state=StreamState}}|Tail], Reason) -> + terminate_stream_handler(State, StreamID, Reason, StreamState), + terminate_all_streams(State, Tail, Reason). + +stream_get(#state{streams=Streams}, StreamID) -> + maps:get(StreamID, Streams, error). + +stream_new_remote(State=#state{http3_machine=HTTP3Machine0, streams=Streams}, + StreamID, StreamType) -> + {HTTP3Machine, Status} = case StreamType of + unidi -> + {cow_http3_machine:init_unidi_stream(StreamID, unidi_remote, HTTP3Machine0), + header}; + bidi -> + {cow_http3_machine:init_bidi_stream(StreamID, HTTP3Machine0), + normal} + end, + Stream = #stream{id=StreamID, status=Status}, + State#state{http3_machine=HTTP3Machine, streams=Streams#{StreamID => Stream}}. + +%% Stream closed message for a local (write-only) unidi stream. +stream_closed(State=#state{local_control_id=StreamID}, StreamID, _) -> + stream_closed1(State, StreamID); +stream_closed(State=#state{local_encoder_id=StreamID}, StreamID, _) -> + stream_closed1(State, StreamID); +stream_closed(State=#state{local_decoder_id=StreamID}, StreamID, _) -> + stream_closed1(State, StreamID); +stream_closed(State=#state{opts=Opts, + streams=Streams0, children=Children0}, StreamID, ErrorCode) -> + case maps:take(StreamID, Streams0) of + {#stream{state=undefined}, Streams} -> + %% Unidi stream has no handler/children. + stream_closed1(State#state{streams=Streams}, StreamID); + %% We only stop bidi streams if the stream was closed with an error + %% or the stream was already in the process of stopping. + {#stream{status=Status, state=StreamState}, Streams} + when Status =:= stopping; ErrorCode =/= 0 -> + terminate_stream_handler(State, StreamID, closed, StreamState), + Children = cowboy_children:shutdown(Children0, StreamID), + stream_closed1(State#state{streams=Streams, children=Children}, StreamID); + %% Don't remove a stream that terminated properly but + %% has chosen to remain up (custom stream handlers). + {_, _} -> + stream_closed1(State, StreamID); + %% Stream closed message for a stream that has been reset. Ignore. + error -> + case is_lingering_stream(State, StreamID) of + true -> + ok; + false -> + %% We avoid logging the data as it could be quite large. + cowboy:log(warning, "Received stream_closed for unknown stream ~p. ~p ~p", + [StreamID, self(), Streams0], Opts) + end, + State + end. + +stream_closed1(State=#state{http3_machine=HTTP3Machine0}, StreamID) -> + case cow_http3_machine:close_stream(StreamID, HTTP3Machine0) of + {ok, HTTP3Machine} -> + State#state{http3_machine=HTTP3Machine}; + {error, Error={connection_error, _, _}, HTTP3Machine} -> + terminate(State#state{http3_machine=HTTP3Machine}, Error) + end. + +stream_store(State=#state{streams=Streams}, Stream=#stream{id=StreamID}) -> + State#state{streams=Streams#{StreamID => Stream}}. + +stream_linger(State=#state{lingering_streams=Lingering0}, StreamID) -> + %% We only keep up to 100 streams in this state. @todo Make it configurable? + Lingering = [StreamID|lists:sublist(Lingering0, 100 - 1)], + State#state{lingering_streams=Lingering}. + +is_lingering_stream(#state{lingering_streams=Lingering}, StreamID) -> + lists:member(StreamID, Lingering). diff --git a/src/cowboy_loop.erl b/src/cowboy_loop.erl index 9d070dbe..6859c824 100644 --- a/src/cowboy_loop.erl +++ b/src/cowboy_loop.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_metrics_h.erl b/src/cowboy_metrics_h.erl index 4107aac0..27f14d41 100644 --- a/src/cowboy_metrics_h.erl +++ b/src/cowboy_metrics_h.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_middleware.erl b/src/cowboy_middleware.erl index 9a739f1f..efeef4f4 100644 --- a/src/cowboy_middleware.erl +++ b/src/cowboy_middleware.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2013-2017, Loïc Hoguin +%% Copyright (c) 2013-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_quicer.erl b/src/cowboy_quicer.erl new file mode 100644 index 00000000..d9bbe1f7 --- /dev/null +++ b/src/cowboy_quicer.erl @@ -0,0 +1,231 @@ +%% Copyright (c) 2023, Loïc Hoguin +%% +%% 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 emqx/quicer NIF. + +-module(cowboy_quicer). + +%% Connection. +-export([peername/1]). +-export([sockname/1]). +-export([peercert/1]). +-export([shutdown/2]). + +%% Streams. +-export([start_unidi_stream/2]). +-export([send/3]). +-export([send/4]). +-export([shutdown_stream/4]). + +%% Messages. +-export([handle/1]). + +-ifndef(COWBOY_QUICER). + +-spec peername(_) -> no_return(). +peername(_) -> no_quicer(). + +-spec sockname(_) -> no_return(). +sockname(_) -> no_quicer(). + +-spec peercert(_) -> no_return(). +peercert(_) -> no_quicer(). + +-spec shutdown(_, _) -> no_return(). +shutdown(_, _) -> no_quicer(). + +-spec start_unidi_stream(_, _) -> no_return(). +start_unidi_stream(_, _) -> no_quicer(). + +-spec send(_, _, _) -> no_return(). +send(_, _, _) -> no_quicer(). + +-spec send(_, _, _, _) -> no_return(). +send(_, _, _, _) -> no_quicer(). + +-spec shutdown_stream(_, _, _, _) -> no_return(). +shutdown_stream(_, _, _, _) -> no_quicer(). + +-spec handle(_) -> no_return(). +handle(_) -> no_quicer(). + +no_quicer() -> + error({no_quicer, + "Cowboy must be compiled with environment variable COWBOY_QUICER=1 " + "or with compilation flag -D COWBOY_QUICER=1 in order to enable " + "QUIC support using the emqx/quic NIF"}). + +-else. + +%% @todo Make quicer export these types. +-type quicer_connection_handle() :: reference(). +-export_type([quicer_connection_handle/0]). + +-type quicer_app_errno() :: non_neg_integer(). + +-include_lib("quicer/include/quicer.hrl"). + +%% Connection. + +-spec peername(quicer_connection_handle()) + -> {ok, {inet:ip_address(), inet:port_number()}} + | {error, any()}. + +peername(Conn) -> + quicer:peername(Conn). + +-spec sockname(quicer_connection_handle()) + -> {ok, {inet:ip_address(), inet:port_number()}} + | {error, any()}. + +sockname(Conn) -> + quicer:sockname(Conn). + +-spec peercert(quicer_connection_handle()) + -> {ok, public_key:der_encoded()} + | {error, any()}. + +peercert(Conn) -> + quicer_nif:peercert(Conn). + +-spec shutdown(quicer_connection_handle(), quicer_app_errno()) + -> ok | {error, any()}. + +shutdown(Conn, ErrorCode) -> + quicer:shutdown_connection(Conn, + ?QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, + ErrorCode). + +%% Streams. + +-spec start_unidi_stream(quicer_connection_handle(), iodata()) + -> {ok, cow_http3:stream_id()} + | {error, any()}. + +start_unidi_stream(Conn, HeaderData) -> + case quicer:start_stream(Conn, #{ + active => true, + open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}) of + {ok, StreamRef} -> + case quicer:send(StreamRef, HeaderData) of + {ok, _} -> + {ok, StreamID} = quicer:get_stream_id(StreamRef), + put({quicer_stream, StreamID}, StreamRef), + {ok, StreamID}; + Error -> + Error + end; + {error, Reason1, Reason2} -> + {error, {Reason1, Reason2}}; + Error -> + Error + end. + +-spec send(quicer_connection_handle(), cow_http3:stream_id(), iodata()) + -> ok | {error, any()}. + +send(Conn, StreamID, Data) -> + send(Conn, StreamID, Data, nofin). + +-spec send(quicer_connection_handle(), cow_http3:stream_id(), iodata(), cow_http:fin()) + -> ok | {error, any()}. + +send(_Conn, StreamID, Data, IsFin) -> + StreamRef = get({quicer_stream, StreamID}), + Size = iolist_size(Data), + case quicer:send(StreamRef, Data, send_flag(IsFin)) of + {ok, Size} -> + ok; + {error, Reason1, Reason2} -> + {error, {Reason1, Reason2}}; + Error -> + Error + end. + +send_flag(nofin) -> ?QUIC_SEND_FLAG_NONE; +send_flag(fin) -> ?QUIC_SEND_FLAG_FIN. + +-spec shutdown_stream(quicer_connection_handle(), + cow_http3:stream_id(), both | receiving, quicer_app_errno()) + -> ok. + +shutdown_stream(_Conn, StreamID, Dir, ErrorCode) -> + StreamRef = get({quicer_stream, StreamID}), + _ = quicer:shutdown_stream(StreamRef, shutdown_flag(Dir), ErrorCode, infinity), + ok. + +shutdown_flag(both) -> ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT; +shutdown_flag(receiving) -> ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT_RECEIVE. + +%% Messages. + +%% @todo Probably should have the Conn given as argument too? +-spec handle({quic, _, _, _}) + -> {data, cow_http3:stream_id(), cow_http:fin(), binary()} + | {stream_started, cow_http3:stream_id(), unidi | bidi} + | {stream_closed, cow_http3:stream_id(), quicer_app_errno()} + | closed + | ok + | unknown + | {socket_error, any()}. + +handle({quic, Data, StreamRef, #{flags := Flags}}) when is_binary(Data) -> + {ok, StreamID} = quicer:get_stream_id(StreamRef), + IsFin = case Flags band ?QUIC_RECEIVE_FLAG_FIN of + ?QUIC_RECEIVE_FLAG_FIN -> fin; + _ -> nofin + end, + {data, StreamID, IsFin, Data}; +%% QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED. +handle({quic, new_stream, StreamRef, #{flags := Flags}}) -> + case quicer:setopt(StreamRef, active, true) of + ok -> + {ok, StreamID} = quicer:get_stream_id(StreamRef), + put({quicer_stream, StreamID}, StreamRef), + StreamType = case quicer:is_unidirectional(Flags) of + true -> unidi; + false -> bidi + end, + {stream_started, StreamID, StreamType}; + {error, Reason} -> + {socket_error, Reason} + end; +%% QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE. +handle({quic, stream_closed, StreamRef, #{error := ErrorCode}}) -> + {ok, StreamID} = quicer:get_stream_id(StreamRef), + {stream_closed, StreamID, ErrorCode}; +%% QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE. +handle({quic, closed, Conn, _Flags}) -> + _ = quicer:close_connection(Conn), + closed; +%% The following events are currently ignored either because +%% I do not know what they do or because we do not need to +%% take action. +handle({quic, streams_available, _Conn, _Props}) -> + ok; +handle({quic, dgram_state_changed, _Conn, _Props}) -> + ok; +%% QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT +handle({quic, transport_shutdown, _Conn, _Flags}) -> + ok; +handle({quic, peer_send_shutdown, _StreamRef, undefined}) -> + ok; +handle({quic, send_shutdown_complete, _StreamRef, _IsGraceful}) -> + ok; +handle({quic, shutdown, _Conn, success}) -> + ok; +handle(_Msg) -> + unknown. + +-endif. diff --git a/src/cowboy_req.erl b/src/cowboy_req.erl index 8edf4ff0..3f876777 100644 --- a/src/cowboy_req.erl +++ b/src/cowboy_req.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% Copyright (c) 2011, Anthony Ramine %% %% Permission to use, copy, modify, and/or distribute this software for any @@ -1024,7 +1024,12 @@ filter([], Map, Errors) -> _ -> {error, Errors} end; filter([{Key, Constraints}|Tail], Map, Errors) -> - filter_constraints(Tail, Map, Errors, Key, maps:get(Key, Map), Constraints); + case maps:find(Key, Map) of + {ok, Value} -> + filter_constraints(Tail, Map, Errors, Key, Value, Constraints); + error -> + filter(Tail, Map, Errors#{Key => required}) + end; filter([{Key, Constraints, Default}|Tail], Map, Errors) -> case maps:find(Key, Map) of {ok, Value} -> diff --git a/src/cowboy_rest.erl b/src/cowboy_rest.erl index 34aa24f0..35921adb 100644 --- a/src/cowboy_rest.erl +++ b/src/cowboy_rest.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -1189,6 +1189,7 @@ if_range(Req=#{headers := #{<<"if-range">> := _, <<"range">> := _}}, if_range(Req, State) -> range(Req, State). +%% @todo This can probably be moved to if_range directly. range(Req, State=#state{ranges_a=[]}) -> set_resp_body(Req, State); range(Req, State) -> diff --git a/src/cowboy_router.erl b/src/cowboy_router.erl index 0b7fe41b..61c9012c 100644 --- a/src/cowboy_router.erl +++ b/src/cowboy_router.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_static.erl b/src/cowboy_static.erl index b0cf1463..a185ef11 100644 --- a/src/cowboy_static.erl +++ b/src/cowboy_static.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2013-2017, Loïc Hoguin +%% Copyright (c) 2013-2024, Loïc Hoguin %% Copyright (c) 2011, Magnus Klaar %% %% Permission to use, copy, modify, and/or distribute this software for any diff --git a/src/cowboy_stream.erl b/src/cowboy_stream.erl index 2dad6d03..6ceb5bae 100644 --- a/src/cowboy_stream.erl +++ b/src/cowboy_stream.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin +%% Copyright (c) 2015-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_stream_h.erl b/src/cowboy_stream_h.erl index f516f3d2..b373344e 100644 --- a/src/cowboy_stream_h.erl +++ b/src/cowboy_stream_h.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -151,6 +151,11 @@ info(StreamID, Exit={'EXIT', Pid, {Reason, Stacktrace}}, State=#state{ref=Ref, p [Ref, self(), StreamID, Pid, Reason, Stacktrace]} |Commands0] end, + %% @todo We are trying to send a 500 response before resetting + %% the stream. But due to the way the RESET_STREAM frame + %% works in QUIC the data may be lost. The problem is + %% known and a draft RFC exists at + %% https://www.ietf.org/id/draft-ietf-quic-reliable-stream-reset-03.html do_info(StreamID, Exit, [ {error_response, 500, #{<<"content-length">> => <<"0">>}, <<>>} |Commands], State); diff --git a/src/cowboy_sub_protocol.erl b/src/cowboy_sub_protocol.erl index 67142898..062fd38f 100644 --- a/src/cowboy_sub_protocol.erl +++ b/src/cowboy_sub_protocol.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2013-2017, Loïc Hoguin +%% Copyright (c) 2013-2024, Loïc Hoguin %% Copyright (c) 2013, James Fish %% %% Permission to use, copy, modify, and/or distribute this software for any diff --git a/src/cowboy_sup.erl b/src/cowboy_sup.erl index d3ac3b0d..e37f4cf4 100644 --- a/src/cowboy_sup.erl +++ b/src/cowboy_sup.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_tls.erl b/src/cowboy_tls.erl index 4385cbc0..60ab2ed5 100644 --- a/src/cowboy_tls.erl +++ b/src/cowboy_tls.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin +%% Copyright (c) 2015-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_tracer_h.erl b/src/cowboy_tracer_h.erl index 9a19ae12..b1196fef 100644 --- a/src/cowboy_tracer_h.erl +++ b/src/cowboy_tracer_h.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/src/cowboy_websocket.erl b/src/cowboy_websocket.erl index e7d8f318..3cc4d303 100644 --- a/src/cowboy_websocket.erl +++ b/src/cowboy_websocket.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -103,7 +103,8 @@ %% is trying to upgrade to the Websocket protocol. -spec is_upgrade_request(cowboy_req:req()) -> boolean(). -is_upgrade_request(#{version := 'HTTP/2', method := <<"CONNECT">>, protocol := Protocol}) -> +is_upgrade_request(#{version := Version, method := <<"CONNECT">>, protocol := Protocol}) + when Version =:= 'HTTP/2'; Version =:= 'HTTP/3' -> <<"websocket">> =:= cowboy_bstr:to_lower(Protocol); is_upgrade_request(Req=#{version := 'HTTP/1.1', method := <<"GET">>}) -> ConnTokens = cowboy_req:parse_header(<<"connection">>, Req, []), @@ -148,13 +149,13 @@ upgrade(Req0=#{version := Version}, Env, Handler, HandlerState, Opts) -> <<"connection">> => <<"upgrade">>, <<"upgrade">> => <<"websocket">> }, Req0), Env}; - %% Use a generic 400 error for HTTP/2. + %% Use 501 Not Implemented for HTTP/2 and HTTP/3 as recommended + %% by RFC9220 3 (WebSockets Upgrade over HTTP/3). {error, upgrade_required} -> - {ok, cowboy_req:reply(400, Req0), Env} + {ok, cowboy_req:reply(501, Req0), Env} catch _:_ -> %% @todo Probably log something here? %% @todo Test that we can have 2 /ws 400 status code in a row on the same connection. - %% @todo Does this even work? {ok, cowboy_req:reply(400, Req0), Env} end. @@ -286,9 +287,12 @@ websocket_handshake(State, Req=#{ref := Ref, pid := Pid, streamid := StreamID}, module() | undefined, any(), binary(), {#state{}, any()}) -> no_return(). takeover(Parent, Ref, Socket, Transport, _Opts, Buffer, - {State0=#state{handler=Handler}, HandlerState}) -> - %% @todo We should have an option to disable this behavior. - ranch:remove_connection(Ref), + {State0=#state{handler=Handler, req=Req}, HandlerState}) -> + case Req of + #{version := 'HTTP/3'} -> ok; + %% @todo We should have an option to disable this behavior. + _ -> ranch:remove_connection(Ref) + end, Messages = case Transport of undefined -> undefined; _ -> Transport:messages() diff --git a/test/compress_SUITE.erl b/test/compress_SUITE.erl index 75f6f5b5..a6a100c9 100644 --- a/test/compress_SUITE.erl +++ b/test/compress_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -23,12 +23,20 @@ %% ct. all() -> - [ + All = [ {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_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_compress}]; + _ -> + All + end. groups() -> cowboy_test:common_groups(ct_helper:all(?MODULE)). @@ -37,7 +45,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. diff --git a/test/cowboy_ct_hook.erl b/test/cowboy_ct_hook.erl index 7d5a889a..e76ec210 100644 --- a/test/cowboy_ct_hook.erl +++ b/test/cowboy_ct_hook.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin +%% Copyright (c) 2014-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl index ed762f74..5a8fb13e 100644 --- a/test/cowboy_test.erl +++ b/test/cowboy_test.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin +%% Copyright (c) 2014-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -37,35 +37,82 @@ init_http2(Ref, ProtoOpts, Config) -> Port = ranch:get_port(Ref), [{ref, Ref}, {type, ssl}, {protocol, http2}, {port, Port}, {opts, Opts}|Config]. +%% @todo This will probably require TransOpts as argument. +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 = #{ + socket_opts => [ + {certfile, DataDir ++ "/server.pem"}, + {keyfile, DataDir ++ "/server.key"} + ] + }, + {ok, Listener} = cowboy:start_quic(Ref, TransOpts, ProtoOpts), + {ok, {_, Port}} = quicer:sockname(Listener), + %% @todo Keep listener information around in a better place. + persistent_term:put({cowboy_test_quic, Ref}, Listener), + [{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config]. + +stop_group(Ref) -> + case persistent_term:get({cowboy_test_quic, Ref}, undefined) of + undefined -> + cowboy:stop_listener(Ref); + Listener -> + quicer:close_listener(Listener) + end. + %% Common group of listeners used by most suites. common_all() -> - [ + All = [ {group, http}, {group, https}, {group, h2}, {group, h2c}, + {group, h3}, {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_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. common_groups(Tests) -> Opts = case os:getenv("NO_PARALLEL") of false -> [parallel]; _ -> [] end, - [ + Groups = [ {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} - ]. + {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. + init_common_groups(Name = http, Config, Mod) -> init_http(Name, #{ @@ -84,6 +131,10 @@ init_common_groups(Name = h2c, Config, Mod) -> env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_common_groups(Name = h3, Config, Mod) -> + init_http3(Name, #{ + env => #{dispatch => Mod:init_dispatch(Config)} + }, [{flavor, vanilla}|Config]); init_common_groups(Name = http_compress, Config, Mod) -> init_http(Name, #{ env => #{dispatch => Mod:init_dispatch(Config)}, @@ -104,7 +155,12 @@ init_common_groups(Name = h2c_compress, Config, Mod) -> env => #{dispatch => Mod:init_dispatch(Config)}, stream_handlers => [cowboy_compress_h, cowboy_stream_h] }, [{flavor, compress}|Config]), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_common_groups(Name = h3_compress, Config, Mod) -> + init_http3(Name, #{ + env => #{dispatch => Mod:init_dispatch(Config)}, + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, compress}|Config]). %% Support functions for testing using Gun. @@ -114,7 +170,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() + false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer. end, {ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{ retry => 0, diff --git a/test/decompress_SUITE.erl b/test/decompress_SUITE.erl index 36144276..f61bb5df 100644 --- a/test/decompress_SUITE.erl +++ b/test/decompress_SUITE.erl @@ -1,4 +1,5 @@ %% Copyright (c) 2024, jdamanalo +%% Copyright (c) 2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -37,6 +38,8 @@ init_per_group(Name = h2, Config) -> init_per_group(Name = h2c, Config) -> Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); init_per_group(Name = http_compress, Config) -> cowboy_test:init_http(Name, init_compress_opts(Config), Config); init_per_group(Name = https_compress, Config) -> @@ -45,7 +48,9 @@ init_per_group(Name = h2_compress, Config) -> cowboy_test:init_http2(Name, init_compress_opts(Config), Config); init_per_group(Name = h2c_compress, Config) -> Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). end_per_group(Name, _) -> cowboy:stop_listener(Name). diff --git a/test/examples_SUITE.erl b/test/examples_SUITE.erl index 1e88f2f2..e2327bcc 100644 --- a/test/examples_SUITE.erl +++ b/test/examples_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/h2spec_SUITE.erl b/test/h2spec_SUITE.erl index 08497e92..67ccf031 100644 --- a/test/h2spec_SUITE.erl +++ b/test/h2spec_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/handlers/echo_h.erl b/test/handlers/echo_h.erl index f50bc792..d04d531d 100644 --- a/test/handlers/echo_h.erl +++ b/test/handlers/echo_h.erl @@ -86,6 +86,7 @@ echo(<<"match">>, Req, Opts) -> Fields = [binary_to_atom(F, latin1) || F <- Fields0], Value = case Type of <<"qs">> -> cowboy_req:match_qs(Fields, Req); + <<"qs_with_constraints">> -> cowboy_req:match_qs([{id, integer}], Req); <<"cookies">> -> cowboy_req:match_cookies(Fields, Req); <<"body_qs">> -> %% Note that the Req should not be discarded but for the diff --git a/test/handlers/resp_h.erl b/test/handlers/resp_h.erl index aae9eb95..6e9b5f75 100644 --- a/test/handlers/resp_h.erl +++ b/test/handlers/resp_h.erl @@ -182,6 +182,7 @@ do(<<"reply2">>, Req0, Opts) -> <<"twice">> -> ct_helper:ignore(cowboy_req, reply, 4), Req1 = cowboy_req:reply(200, Req0), + timer:sleep(100), cowboy_req:reply(200, Req1); Status -> cowboy_req:reply(binary_to_integer(Status), Req0) @@ -245,6 +246,7 @@ do(<<"stream_reply2">>, Req0, Opts) -> <<"twice">> -> ct_helper:ignore(cowboy_req, stream_reply, 3), Req1 = cowboy_req:stream_reply(200, Req0), + timer:sleep(100), %% We will crash here so the body shouldn't be sent. Req = cowboy_req:stream_reply(200, Req1), stream_body(Req), diff --git a/test/http2_SUITE.erl b/test/http2_SUITE.erl index 37dfea3e..d17508a3 100644 --- a/test/http2_SUITE.erl +++ b/test/http2_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index fb9f93b3..0325279d 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -90,7 +90,7 @@ chunked_one_byte_at_a_time(Config) -> "Transfer-encoding: chunked\r\n\r\n"), _ = [begin raw_send(Client, <>), - timer:sleep(10) + timer:sleep(1) end || <> <= ChunkedBody], Rest = case catch raw_recv_head(Client) of {'EXIT', _} -> error(closed); @@ -329,6 +329,7 @@ do_idle_timeout_on_send(Config, Protocol) -> try ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), #{socket := Socket} = gun:info(ConnPid), Pid = get_remote_pid_tcp(Socket), StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), @@ -359,6 +360,7 @@ do_idle_timeout_reset_on_send(Config, Protocol) -> try ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), #{socket := Socket} = gun:info(ConnPid), Pid = get_remote_pid_tcp(Socket), StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), diff --git a/test/loop_handler_SUITE.erl b/test/loop_handler_SUITE.erl index 3a91e084..c5daaf84 100644 --- a/test/loop_handler_SUITE.erl +++ b/test/loop_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. diff --git a/test/metrics_SUITE.erl b/test/metrics_SUITE.erl index ad4f5fb0..6a272f21 100644 --- a/test/metrics_SUITE.erl +++ b/test/metrics_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -44,6 +44,8 @@ init_per_group(Name = h2, Config) -> init_per_group(Name = h2c, Config) -> Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); init_per_group(Name = http_compress, Config) -> cowboy_test:init_http(Name, init_compress_opts(Config), Config); init_per_group(Name = https_compress, Config) -> @@ -52,10 +54,12 @@ init_per_group(Name = h2_compress, Config) -> cowboy_test:init_http2(Name, init_compress_opts(Config), Config); init_per_group(Name = h2c_compress, Config) -> Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_plain_opts(Config) -> #{ @@ -157,16 +161,24 @@ do_get(Path, UserData, Config) -> #{ ref := _, pid := From, - streamid := 1, - reason := normal, + streamid := StreamID, + reason := normal, %% @todo Getting h3_no_error here. req := #{}, informational := [], user_data := UserData } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. +do_check_streamid(StreamID, Config) -> + case config(protocol, Config) of + http -> 1 = StreamID; + http2 -> 1 = StreamID; + http3 -> 0 = StreamID + end. + post_body(Config) -> doc("Confirm metrics are correct for a normal POST request."), %% Perform a POST request. @@ -218,12 +230,13 @@ post_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -273,12 +286,13 @@ no_resp_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -291,7 +305,8 @@ early_error(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -305,7 +320,7 @@ early_error(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {stream_error, Error, _}, partial_req := #{}, resp_status := Status, @@ -313,6 +328,7 @@ early_error(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! gun:close(ConnPid) @@ -321,7 +337,8 @@ early_error(Config) -> early_error_request_line(Config) -> case config(protocol, Config) of http -> do_early_error_request_line(Config); - http2 -> doc("There are no request lines in HTTP/2.") + http2 -> doc("There are no request lines in HTTP/2."); + http3 -> doc("There are no request lines in HTTP/3.") end. do_early_error_request_line(Config) -> @@ -341,7 +358,7 @@ do_early_error_request_line(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {connection_error, protocol_error, _}, partial_req := #{}, resp_status := 400, @@ -349,6 +366,7 @@ do_early_error_request_line(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! ok @@ -362,7 +380,9 @@ stream_reply(Config) -> ws(Config) -> case config(protocol, Config) of http -> do_ws(Config); - http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2.") + %% @todo The test can be implemented for HTTP/2. + http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2."); + http3 -> {skip, "Gun does not currently support Websocket over HTTP/3."} end. do_ws(Config) -> @@ -405,7 +425,7 @@ do_ws(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := switch_protocol, req := #{}, %% A 101 upgrade response was sent. @@ -420,6 +440,7 @@ do_ws(Config) -> }], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! ok end, @@ -438,7 +459,15 @@ error_response(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 500, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 500, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -463,7 +492,14 @@ error_response(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ @@ -476,12 +512,13 @@ error_response(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -495,7 +532,15 @@ error_response_after_reply(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 200, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -520,7 +565,14 @@ error_response_after_reply(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ @@ -533,12 +585,13 @@ error_response_after_reply(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. diff --git a/test/misc_SUITE.erl b/test/misc_SUITE.erl index ac8f1ddd..c918321b 100644 --- a/test/misc_SUITE.erl +++ b/test/misc_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -43,7 +43,7 @@ init_per_group(Name, Config) -> end_per_group(env, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"localhost", [ diff --git a/test/plain_handler_SUITE.erl b/test/plain_handler_SUITE.erl index e980d5ba..756c0a61 100644 --- a/test/plain_handler_SUITE.erl +++ b/test/plain_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -39,7 +39,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -58,8 +58,15 @@ crash_after_reply(Config) -> Ref = gun:get(ConnPid, "/crash/reply", [ {<<"accept-encoding">>, <<"gzip">>} ]), - {response, fin, 200, _} = gun:await(ConnPid, Ref), - {error, timeout} = gun:await(ConnPid, Ref, 1000), + Protocol = config(protocol, Config), + _ = case gun:await(ConnPid, Ref) of + {response, fin, 200, _} -> + {error, timeout} = gun:await(ConnPid, Ref, 1000); + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} + when Protocol =:= http3 -> + ok + end, gun:close(ConnPid). crash_before_reply(Config) -> diff --git a/test/proxy_header_SUITE.erl b/test/proxy_header_SUITE.erl index 9d1ca2f9..cb6ab47a 100644 --- a/test/proxy_header_SUITE.erl +++ b/test/proxy_header_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/req_SUITE.erl b/test/req_SUITE.erl index f407853e..9036cac9 100644 --- a/test/req_SUITE.erl +++ b/test/req_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -46,7 +46,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -57,7 +57,8 @@ init_dispatch(Config) -> {"/resp/:key[/:arg]", resp_h, []}, {"/multipart[/:key]", multipart_h, []}, {"/args/:key/:arg[/:default]", echo_h, []}, - {"/crash/:key/period", echo_h, #{length => 999999999, period => 1000, crash => true}}, + {"/crash/:key/period", echo_h, + #{length => 999999999, period => 1000, timeout => 5000, crash => true}}, {"/no-opts/:key", echo_h, #{crash => true}}, {"/opts/:key/length", echo_h, #{length => 1000}}, {"/opts/:key/period", echo_h, #{length => 999999999, period => 2000}}, @@ -106,13 +107,17 @@ do_get(Path, Config) -> do_get(Path, Headers, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|Headers]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref, infinity), - {ok, RespBody} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref, infinity); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, do_decode(RespHeaders, RespBody)}. + case gun:await(ConnPid, Ref, infinity) of + {response, IsFin, Status, RespHeaders} -> + {ok, RespBody} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref, infinity); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + {Status, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error + end. do_get_body(Path, Config) -> do_get_body(Path, [], Config). @@ -141,7 +146,9 @@ do_get_inform(Path, Config) -> fin -> {ok, <<>>} end, gun:close(ConnPid), - {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)} + {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error end. do_decode(Headers, Body) -> @@ -183,7 +190,8 @@ bindings(Config) -> cert(Config) -> case config(type, Config) of tcp -> doc("TLS certificates can only be provided over TLS."); - ssl -> do_cert(Config) + ssl -> do_cert(Config); + quic -> do_cert(Config) end. do_cert(Config) -> @@ -265,6 +273,7 @@ match_qs(Config) -> end, %% Ensure match errors result in a 400 response. {400, _, _} = do_get("/match/qs/a/c?a=b", [], Config), + {400, _, _} = do_get("/match/qs_with_constraints", [], Config), %% This function is tested more extensively through unit tests. ok. @@ -384,7 +393,8 @@ port(Config) -> Port = do_get_body("/direct/port", Config), ExpectedPort = case config(type, Config) of tcp -> <<"80">>; - ssl -> <<"443">> + ssl -> <<"443">>; + quic -> <<"443">> end, ExpectedPort = do_get_body("/port", [{<<"host">>, <<"localhost">>}], Config), ExpectedPort = do_get_body("/direct/port", [{<<"host">>, <<"localhost">>}], Config), @@ -410,7 +420,8 @@ do_scheme(Path, Config) -> Transport = config(type, Config), case do_get_body(Path, Config) of <<"http">> when Transport =:= tcp -> ok; - <<"https">> when Transport =:= ssl -> ok + <<"https">> when Transport =:= ssl -> ok; + <<"https">> when Transport =:= quic -> ok end. sock(Config) -> @@ -423,7 +434,8 @@ uri(Config) -> doc("Request URI building/modification."), Scheme = case config(type, Config) of tcp -> <<"http">>; - ssl -> <<"https">> + ssl -> <<"https">>; + quic -> <<"https">> end, SLen = byte_size(Scheme), Port = integer_to_binary(config(port, Config)), @@ -457,7 +469,8 @@ do_version(Path, Config) -> Protocol = config(protocol, Config), case do_get_body(Path, Config) of <<"HTTP/1.1">> when Protocol =:= http -> ok; - <<"HTTP/2">> when Protocol =:= http2 -> ok + <<"HTTP/2">> when Protocol =:= http2 -> ok; + <<"HTTP/3">> when Protocol =:= http3 -> ok end. %% Tests: Request body. @@ -511,11 +524,19 @@ read_body_period(Config) -> %% for 2 seconds. The test succeeds if we get some of the data back %% (meaning the function will have returned after the period ends). gun:data(ConnPid, Ref, nofin, Body), - {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), - {data, _, Data} = gun:await(ConnPid, Ref, infinity), - %% We expect to read at least some data. - true = Data =/= <<>>, - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + case Response of + {response, nofin, 200, _} -> + {data, _, Data} = gun:await(ConnPid, Ref, infinity), + %% We expect to read at least some data. + true = Data =/= <<>>, + gun:close(ConnPid); + %% We got a crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 500, _} -> + gun:close(ConnPid), + read_body_period(Config) + end. %% We expect a crash. do_read_body_timeout(Path, Body, Config) -> @@ -523,7 +544,13 @@ do_read_body_timeout(Path, Body, Config) -> Ref = gun:headers(ConnPid, "POST", Path, [ {<<"content-length">>, integer_to_binary(byte_size(Body))} ]), - {response, _, 500, _} = gun:await(ConnPid, Ref, infinity), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 500, _} -> + ok; + %% See do_maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, gun:close(ConnPid). read_body_auto(Config) -> @@ -590,8 +617,20 @@ do_read_urlencoded_body_too_large(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(iolist_size(Body))} ]), gun:data(ConnPid, Ref, fin, Body), - {response, _, 413, _} = gun:await(ConnPid, Ref, infinity), - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + gun:close(ConnPid), + case Response of + {response, _, 413, _} -> + ok; + %% We got the wrong crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 408, _} -> + do_read_urlencoded_body_too_large(Path, Body, Config); + %% Timing issues make it possible for the connection to be + %% closed before the data went through. We retry. + {error, {stream_error, {closed, {error,closed}}}} -> + do_read_urlencoded_body_too_large(Path, Body, Config) + end. read_urlencoded_body_too_long(Config) -> doc("application/x-www-form-urlencoded request body sent too slow. " @@ -606,15 +645,19 @@ do_read_urlencoded_body_too_long(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(byte_size(Body) * 2)} ]), gun:data(ConnPid, Ref, nofin, Body), - {response, _, 408, RespHeaders} = gun:await(ConnPid, Ref, infinity), - _ = case config(protocol, Config) of - http -> + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 408, RespHeaders} when Protocol =:= http -> %% 408 error responses should close HTTP/1.1 connections. - {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders); - http2 -> - ok - end, - gun:close(ConnPid). + {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders), + gun:close(ConnPid); + {response, _, 408, _} when Protocol =:= http2; Protocol =:= http3 -> + gun:close(ConnPid); + %% We must have hit the timeout due to busy CI environment. Retry. + {response, _, 500, _} -> + gun:close(ConnPid), + do_read_urlencoded_body_too_long(Path, Body, Config) + end. read_and_match_urlencoded_body(Config) -> doc("Read and match an application/x-www-form-urlencoded request body."), @@ -810,7 +853,7 @@ set_resp_header(Config) -> {200, Headers, <<"OK">>} = do_get("/resp/set_resp_header", Config), true = lists:keymember(<<"content-type">>, 1, Headers), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _, _} = do_get("/resp/set_resp_header_cookie", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_header_cookie", Config)), ok. set_resp_headers(Config) -> @@ -819,7 +862,7 @@ set_resp_headers(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers), true = lists:keymember(<<"content-encoding">>, 1, Headers), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _, _} = do_get("/resp/set_resp_headers_cookie", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_cookie", Config)), ok. resp_header(Config) -> @@ -881,28 +924,52 @@ delete_resp_header(Config) -> false = lists:keymember(<<"content-type">>, 1, Headers), ok. +%% Data may be lost due to how RESET_STREAM QUIC frame works. +%% Because there is ongoing work for a better way to reset streams +%% (https://www.ietf.org/archive/id/draft-ietf-quic-reliable-stream-reset-03.html) +%% we convert the error to a 500 to keep the tests more explicit +%% at what we expect. +%% @todo When RESET_STREAM_AT gets added we can remove this function. +do_maybe_h3_error2({stream_error, h3_internal_error, _}) -> {500, []}; +do_maybe_h3_error2(Result) -> Result. + +do_maybe_h3_error3({stream_error, h3_internal_error, _}) -> {500, [], <<>>}; +do_maybe_h3_error3(Result) -> Result. + inform2(Config) -> doc("Informational response(s) without headers, followed by the real response."), {102, [], 200, _, _} = do_get_inform("/resp/inform2/102", Config), {102, [], 200, _, _} = do_get_inform("/resp/inform2/binary", Config), - {500, _} = do_get_inform("/resp/inform2/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform2/error", Config)), {102, [], 200, _, _} = do_get_inform("/resp/inform2/twice", Config), - %% @todo How to test this properly? This isn't enough. - {200, _} = do_get_inform("/resp/inform2/after_reply", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform2/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. inform3(Config) -> doc("Informational response(s) with headers, followed by the real response."), Headers = [{<<"ext-header">>, <<"ext-value">>}], {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/102", Config), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/binary", Config), - {500, _} = do_get_inform("/resp/inform3/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/error", Config)), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _} = do_get_inform("/resp/inform3/set_cookie", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/set_cookie", Config)), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/twice", Config), - %% @todo How to test this properly? This isn't enough. - {200, _} = do_get_inform("/resp/inform3/after_reply", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform3/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. reply2(Config) -> doc("Response with default headers and no body."), @@ -910,7 +977,7 @@ reply2(Config) -> {201, _, _} = do_get("/resp/reply2/201", Config), {404, _, _} = do_get("/resp/reply2/404", Config), {200, _, _} = do_get("/resp/reply2/binary", Config), - {500, _, _} = do_get("/resp/reply2/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply2/error", Config)), %% @todo How to test this properly? This isn't enough. {200, _, _} = do_get("/resp/reply2/twice", Config), ok. @@ -923,9 +990,9 @@ reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, _} = do_get("/resp/reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/error", Config)), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _, _} = do_get("/resp/reply3/set_cookie", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/set_cookie", Config)), ok. reply4(Config) -> @@ -933,9 +1000,9 @@ reply4(Config) -> {200, _, <<"OK">>} = do_get("/resp/reply4/200", Config), {201, _, <<"OK">>} = do_get("/resp/reply4/201", Config), {404, _, <<"OK">>} = do_get("/resp/reply4/404", Config), - {500, _, _} = do_get("/resp/reply4/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/error", Config)), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _, _} = do_get("/resp/reply4/set_cookie", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/set_cookie", Config)), ok. stream_reply2(Config) -> @@ -945,12 +1012,11 @@ stream_reply2(Config) -> {201, _, Body} = do_get("/resp/stream_reply2/201", Config), {404, _, Body} = do_get("/resp/stream_reply2/404", Config), {200, _, Body} = do_get("/resp/stream_reply2/binary", Config), - {500, _, _} = do_get("/resp/stream_reply2/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply2/error", Config)), ok. stream_reply2_twice(Config) -> - doc("Attempting to stream a response twice results in a crash. " - "This crash can only be properly detected in HTTP/2."), + doc("Attempting to stream a response twice results in a crash."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/resp/stream_reply2/twice", [{<<"accept-encoding">>, <<"gzip">>}]), @@ -969,8 +1035,10 @@ stream_reply2_twice(Config) -> zlib:inflateInit(Z, 31), 0 = iolist_size(zlib:inflate(Z, Data)), ok; - %% In HTTP/2 the stream gets reset with an appropriate error. + %% In HTTP/2 and HTTP/3 the stream gets reset with an appropriate error. {http2, _, {error, {stream_error, {stream_error, internal_error, _}}}} -> + ok; + {http3, _, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> ok end, gun:close(ConnPid). @@ -984,9 +1052,9 @@ stream_reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, Body} = do_get("/resp/stream_reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/stream_reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/error", Config)), %% The set-cookie header is special. set_resp_cookie must be used. - {500, _, _} = do_get("/resp/stream_reply3/set_cookie", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/set_cookie", Config)), ok. stream_body_fin0(Config) -> @@ -1070,8 +1138,11 @@ stream_body_content_length_nofin_error(Config) -> end end; http2 -> - %% @todo HTTP2 should have the same content-length checks - ok + %% @todo HTTP/2 should have the same content-length checks. + {skip, "Implement the test for HTTP/2."}; + http3 -> + %% @todo HTTP/3 should have the same content-length checks. + {skip, "Implement the test for HTTP/3."} end. stream_body_concurrent(Config) -> @@ -1173,16 +1244,24 @@ stream_trailers_set_cookie(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"te">>, <<"trailers">>} ]), - {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), - case config(protocol, Config) of - http -> + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, nofin, 200, _} when Protocol =:= http -> %% Trailers are not sent because of the stream error. {ok, _Body} = gun:await_body(ConnPid, Ref, infinity), {error, timeout} = gun:await_body(ConnPid, Ref, 1000), ok; - http2 -> + {response, nofin, 200, _} when Protocol =:= http2 -> {error, {stream_error, {stream_error, internal_error, _}}} = gun:await_body(ConnPid, Ref, infinity), + ok; + {response, nofin, 200, _} when Protocol =:= http3 -> + {error, {stream_error, {stream_error, h3_internal_error, _}}} + = gun:await_body(ConnPid, Ref, infinity), + ok; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> ok end, gun:close(ConnPid). @@ -1210,34 +1289,45 @@ do_trailers(Path, Config) -> push(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push", Config); - http2 -> do_push_http2(Config) + http2 -> do_push_http2(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_after_reply(Config) -> doc("Trying to push a response after the final response results in a crash."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/resp/push/after_reply", []), - %% @todo How to test this properly? This isn't enough. - {response, fin, 200, _} = gun:await(ConnPid, Ref, infinity), + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, _} -> + ok; + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, gun:close(ConnPid). push_method(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/method", Config); - http2 -> do_push_http2_method(Config) + http2 -> do_push_http2_method(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_origin(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/origin", Config); - http2 -> do_push_http2_origin(Config) + http2 -> do_push_http2_origin(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_qs(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/qs", Config); - http2 -> do_push_http2_qs(Config) + http2 -> do_push_http2_qs(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. do_push_http(Path, Config) -> diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl index d8998e9a..4af86b68 100644 --- a/test/rest_handler_SUITE.erl +++ b/test/rest_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. @@ -85,7 +85,7 @@ accept_callback_missing(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"content-type">>, <<"text/plain">>} ], <<"Missing!">>), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. accept_callback_patch_false(Config) -> @@ -472,7 +472,8 @@ delete_resource_missing(Config) -> Ref = gun:delete(ConnPid, "/delete_resource?missing", [ {<<"accept-encoding">>, <<"gzip">>} ]), - {response, _, 500, Headers} = gun:await(ConnPid, Ref), + + {response, _, 500, Headers} = do_maybe_h3_error(gun:await(ConnPid, Ref)), {_, <<"DELETE">>} = lists:keyfind(<<"allow">>, 1, Headers), ok. @@ -653,10 +654,16 @@ do_generate_etag(Config, Qs, ReqHeaders, Status, Etag) -> {<<"accept-encoding">>, <<"gzip">>} |ReqHeaders ]), - {response, _, Status, RespHeaders} = gun:await(ConnPid, Ref), + {response, _, Status, RespHeaders} = do_maybe_h3_error(gun:await(ConnPid, Ref)), Etag = lists:keyfind(<<"etag">>, 1, RespHeaders), ok. +%% See do_maybe_h3_error2 comment. +do_maybe_h3_error({error, {stream_error, {stream_error, h3_internal_error, _}}}) -> + {response, fin, 500, []}; +do_maybe_h3_error(Result) -> + Result. + if_range_etag_equal(Config) -> doc("When the if-range header matches, a 206 partial content " "response is expected for an otherwise valid range request. (RFC7233 3.2)"), @@ -821,7 +828,7 @@ provide_callback_missing(Config) -> doc("A 500 response must be sent when the ProvideCallback can't be called."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/provide_callback_missing", [{<<"accept-encoding">>, <<"gzip">>}]), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. provide_range_callback(Config) -> @@ -977,7 +984,7 @@ provide_range_callback_missing(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"range">>, <<"bytes=0-">>} ]), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. range_ignore_unknown_unit(Config) -> diff --git a/test/rfc6585_SUITE.erl b/test/rfc6585_SUITE.erl index 1f65f786..17cbb076 100644 --- a/test/rfc6585_SUITE.erl +++ b/test/rfc6585_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7230_SUITE.erl b/test/rfc7230_SUITE.erl index 64d9ce4a..17d1905a 100644 --- a/test/rfc7230_SUITE.erl +++ b/test/rfc7230_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin +%% Copyright (c) 2015-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/rfc7231_SUITE.erl b/test/rfc7231_SUITE.erl index a943d6d1..4475899a 100644 --- a/test/rfc7231_SUITE.erl +++ b/test/rfc7231_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -35,7 +35,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ @@ -237,6 +237,8 @@ http10_expect(Config) -> http -> do_http10_expect(Config); http2 -> + expect(Config); + http3 -> expect(Config) end. @@ -303,6 +305,9 @@ expect_discard_body_close(Config) -> do_expect_discard_body_close(Config); http2 -> doc("There's no reason to close the connection when using HTTP/2, " + "even if a stream body is too large. We just cancel the stream."); + http3 -> + doc("There's no reason to close the connection when using HTTP/3, " "even if a stream body is too large. We just cancel the stream.") end. @@ -424,8 +429,10 @@ http10_status_code_100(Config) -> http -> doc("The 100 Continue status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(100, Config); + do_unsupported_status_code_1xx(100, Config); http2 -> + status_code_100(Config); + http3 -> status_code_100(Config) end. @@ -434,12 +441,16 @@ http10_status_code_101(Config) -> http -> doc("The 101 Switching Protocols status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(101, Config); + do_unsupported_status_code_1xx(101, Config); http2 -> + status_code_101(Config); + http3 -> + %% While 101 is not supported by HTTP/3, there is no + %% wording in RFC9114 that forbids sending it. status_code_101(Config) end. -do_http10_status_code_1xx(StatusCode, Config) -> +do_unsupported_status_code_1xx(StatusCode, Config) -> ConnPid = gun_open(Config, #{http_opts => #{version => 'HTTP/1.0'}}), Ref = gun:get(ConnPid, "/resp/inform2/" ++ integer_to_list(StatusCode), [ {<<"accept-encoding">>, <<"gzip">>} @@ -653,7 +664,9 @@ status_code_408_connection_close(Config) -> http -> do_http11_status_code_408_connection_close(Config); http2 -> - doc("HTTP/2 connections are not closed on 408 responses.") + doc("HTTP/2 connections are not closed on 408 responses."); + http3 -> + doc("HTTP/3 connections are not closed on 408 responses.") end. do_http11_status_code_408_connection_close(Config) -> @@ -744,7 +757,9 @@ status_code_426_upgrade_header(Config) -> http -> do_status_code_426_upgrade_header(Config); http2 -> - doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism.") + doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism."); + http3 -> + doc("HTTP/3 does not support the HTTP/1.1 Upgrade mechanism.") end. do_status_code_426_upgrade_header(Config) -> diff --git a/test/rfc7538_SUITE.erl b/test/rfc7538_SUITE.erl index 5eb97054..c46d3883 100644 --- a/test/rfc7538_SUITE.erl +++ b/test/rfc7538_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl index 986c3767..f0406014 100644 --- a/test/rfc7540_SUITE.erl +++ b/test/rfc7540_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -34,9 +34,9 @@ all() -> [{group, clear}, {group, tls}]. groups() -> - Modules = ct_helper:all(?MODULE), - Clear = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =/= "alpn"] -- [prior_knowledge_reject_tls], - TLS = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls], + Tests = ct_helper:all(?MODULE), + Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- [prior_knowledge_reject_tls], + TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls], [{clear, [parallel], Clear}, {tls, [parallel], TLS}]. init_per_group(Name = clear, Config) -> @@ -3893,6 +3893,7 @@ accept_host_header_on_missing_pseudo_header_authority(Config) -> %% When both :authority and host headers are received, the current behavior %% is to favor :authority and ignore the host header. The specification does %% not describe the correct behavior to follow in that case. +%% @todo The HTTP/3 spec says both values must be identical and non-empty. reject_many_pseudo_header_authority(Config) -> doc("A request containing more than one authority component must be rejected " diff --git a/test/rfc8297_SUITE.erl b/test/rfc8297_SUITE.erl index 9ae6180d..c6c1c9df 100644 --- a/test/rfc8297_SUITE.erl +++ b/test/rfc8297_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc8441_SUITE.erl b/test/rfc8441_SUITE.erl index 245658fa..3e71667c 100644 --- a/test/rfc8441_SUITE.erl +++ b/test/rfc8441_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -126,6 +126,7 @@ reject_handshake_disabled_by_default(Config0) -> % The Extended CONNECT Method. +%% @todo Refer to RFC9110 7.8 about the case insensitive comparison. accept_uppercase_pseudo_header_protocol(Config) -> doc("The :protocol pseudo header is case insensitive. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -172,6 +173,7 @@ reject_many_pseudo_header_protocol(Config) -> ok. reject_unknown_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an unknown protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -192,10 +194,11 @@ reject_unknown_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_invalid_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an invalid protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -216,7 +219,7 @@ reject_invalid_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_missing_pseudo_header_scheme(Config) -> @@ -293,7 +296,7 @@ reject_missing_pseudo_header_protocol(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request without a :protocol pseudo-header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":scheme">>, <<"http">>}, @@ -317,7 +320,7 @@ reject_connection_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a connection header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, @@ -339,7 +342,7 @@ reject_upgrade_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a upgrade header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, diff --git a/test/rfc9114_SUITE.erl b/test/rfc9114_SUITE.erl new file mode 100644 index 00000000..4a36ee14 --- /dev/null +++ b/test/rfc9114_SUITE.erl @@ -0,0 +1,2426 @@ +%% Copyright (c) 2023-2024, Loïc Hoguin +%% +%% 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_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +-ifdef(COWBOY_QUICER). + +-include_lib("quicer/include/quicer.hrl"). + +all() -> + [{group, h3}]. + +groups() -> + %% @todo Enable parallel tests but for this issues in the + %% QUIC accept loop need to be figured out (can't connect + %% concurrently somehow, no backlog?). + [{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, []} + ]} +]. + +%% Starting HTTP/3 for "https" URIs. + +alpn(Config) -> + doc("Successful ALPN negotiation. (RFC9114 3.1)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + {ok, <<"h3">>} = quicer:negotiated_protocol(Conn), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, _ControlRef, _Settings} = do_wait_settings(Conn), + ok. + +alpn_error(Config) -> + doc("Failed ALPN negotiation using the 'h2' token. (RFC9114 3.1)"), + {error, transport_down, #{status := alpn_neg_failure}} + = quicer:connect("localhost", config(port, Config), + #{alpn => ["h2"], verify => none}, 5000), + ok. + +%% @todo 3.2. Connection Establishment +%% After the QUIC connection is established, a SETTINGS frame MUST be sent by each endpoint as the initial frame of their respective HTTP control stream. + +%% @todo 3.3. Connection Reuse +%% Servers are encouraged to maintain open HTTP/3 connections for as long as +%possible but are permitted to terminate idle connections if necessary. When +%either endpoint chooses to close the HTTP/3 connection, the terminating +%endpoint SHOULD first send a GOAWAY frame (Section 5.2) so that both endpoints +%can reliably determine whether previously sent frames have been processed and +%gracefully complete or terminate any necessary remaining tasks. + +%% Frame format. + +req_stream(Config) -> + doc("Complete lifecycle of a request stream. (RFC9114 4.1)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, _Settings} = do_wait_settings(Conn), + %% Send a request on a request stream. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], ?QUIC_SEND_FLAG_FIN), + %% Receive the response. + {ok, Data} = do_receive_data(StreamRef), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + Rest/bits + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{ + <<":status">> := <<"200">>, + <<"content-length">> := BodyLen + } = maps:from_list(DecodedResponse), + {DLenEnc, DLenBits} = do_guess_int_encoding(Rest), + << + 0, %% DATA frame. + DLenEnc:2, DLen:DLenBits, + Body:DLen/bytes + >> = Rest, + <<"Hello world!">> = Body, + BodyLen = integer_to_binary(byte_size(Body)), + ok = do_wait_peer_send_shutdown(StreamRef), + ok = do_wait_stream_closed(StreamRef). + +%% @todo Same test as above but with content-length unset? + +req_stream_two_requests(Config) -> + doc("Receipt of multiple requests on a single stream must " + "be rejected with an H3_MESSAGE_ERROR stream error. " + "(RFC9114 4.1, RFC9114 4.1.2)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, _Settings} = do_wait_settings(Conn), + %% Send two requests on a request stream. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest1, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedRequest2, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest1)), + EncodedRequest1, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest2)), + EncodedRequest2 + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +headers_then_trailers(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS must be accepted. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by trailer HEADERS " + "must be accepted. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +data_then_headers(Config) -> + doc("Receipt of DATA before HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_trailers_then_data(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_data_then_trailers_then_data(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_data_then_trailers_then_trailers(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers1, _EncData2, EncSt1} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, EncodedTrailers2, _EncData3, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt1), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers1)), + EncodedTrailers1, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers2)), + EncodedTrailers2 + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +unknown_then_headers(Config) -> + doc("Receipt of unknown frame followed by HEADERS " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + unknown_then_headers(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +unknown_then_headers(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown(Config) -> + doc("Receipt of HEADERS followed by DATA followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_trailers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_trailers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "unknown frame followed by trailer HEADERS " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown_then_trailers(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown_then_trailers(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown_then_data(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "unknown frame followed by DATA " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown_then_data(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown_then_data(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(6), + <<"Hello ">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<0>>, %% DATA frame. + cow_http3:encode_int(7), + <<"server!">> + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_trailers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "trailer HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_trailers_then_unknown(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_trailers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +do_unknown_frame_type() -> + Type = rand:uniform(4611686018427387904) - 1, + %% Retry if we get a value that's specified. + case lists:member(Type, [ + 16#0, 16#1, 16#3, 16#4, 16#5, 16#7, 16#d, %% HTTP/3 core frame types. + 16#2, 16#6, 16#8, 16#9 %% HTTP/3 reserved frame types that must be rejected. + ]) of + true -> do_unknown_frame_type(); + false -> Type + end. + +reserved_then_headers(Config) -> + doc("Receipt of reserved frame followed by HEADERS " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + unknown_then_headers(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved(Config) -> + doc("Receipt of HEADERS followed by DATA followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_trailers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_trailers_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "reserved frame followed by trailer HEADERS " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown_then_trailers(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved_then_data(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "reserved frame followed by DATA " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown_then_data(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_trailers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "trailer HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_trailers_then_unknown(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +reject_transfer_encoding_header_with_body(Config) -> + doc("Requests containing a transfer-encoding header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.1, RFC9114 4.1.2, RFC9114 4.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"transfer-encoding">>, <<"chunked">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(24), + <<"13\r\nHello server!\r\n0\r\n\r\n">> + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% 4. Expressing HTTP Semantics in HTTP/3 +%% 4.1. HTTP Message Framing + +%% An HTTP request/response exchange fully consumes a client-initiated +%bidirectional QUIC stream. After sending a request, a client MUST close the +%stream for sending. Unless using the CONNECT method (see Section 4.4), clients +%MUST NOT make stream closure dependent on receiving a response to their +%request. After sending a final response, the server MUST close the stream for +%sending. At this point, the QUIC stream is fully closed. +%% @todo What to do with clients that DON'T close the stream +%% for sending after the request is sent? + +%% If a client-initiated stream terminates without enough of the HTTP message +%to provide a complete response, the server SHOULD abort its response stream +%with the error code H3_REQUEST_INCOMPLETE. +%% @todo difficult!! + +%% When the server does not need to receive the remainder of the request, it +%MAY abort reading the request stream, send a complete response, and cleanly +%close the sending part of the stream. The error code H3_NO_ERROR SHOULD be +%used when requesting that the client stop sending on the request stream. +%% @todo read_body related; h2 has this behavior but there is no corresponding test + +%% 4.1.1. Request Cancellation and Rejection + +%% When possible, it is RECOMMENDED that servers send an HTTP response with an +%appropriate status code rather than cancelling a request it has already begun +%processing. + +%% Implementations SHOULD cancel requests by abruptly terminating any +%directions of a stream that are still open. To do so, an implementation resets +%the sending parts of streams and aborts reading on the receiving parts of +%streams; see Section 2.4 of [QUIC-TRANSPORT]. + +%% When the server cancels a request without performing any application +%processing, the request is considered "rejected". The server SHOULD abort its +%response stream with the error code H3_REQUEST_REJECTED. In this context, +%"processed" means that some data from the stream was passed to some higher +%layer of software that might have taken some action as a result. The client +%can treat requests rejected by the server as though they had never been sent +%at all, thereby allowing them to be retried later. + +%% Servers MUST NOT use the H3_REQUEST_REJECTED error code for requests that +%were partially or fully processed. When a server abandons a response after +%partial processing, it SHOULD abort its response stream with the error code +%H3_REQUEST_CANCELLED. +%% @todo + +%% Client SHOULD use the error code H3_REQUEST_CANCELLED to cancel requests. +%Upon receipt of this error code, a server MAY abruptly terminate the response +%using the error code H3_REQUEST_REJECTED if no processing was performed. +%Clients MUST NOT use the H3_REQUEST_REJECTED error code, except when a server +%has requested closure of the request stream with this error code. +%% @todo + +%4.1.2. Malformed Requests and Responses +%A malformed request or response is one that is an otherwise valid sequence of +%frames but is invalid due to: +% +%the presence of prohibited fields or pseudo-header fields, +%% @todo reject_response_pseudo_headers +%% @todo reject_unknown_pseudo_headers +%% @todo reject_pseudo_headers_in_trailers + +%the absence of mandatory pseudo-header fields, +%invalid values for pseudo-header fields, +%pseudo-header fields after fields, +%% @todo reject_pseudo_headers_after_regular_headers + +%an invalid sequence of HTTP messages, +%the inclusion of invalid characters in field names or values. +% +%A request or response that is defined as having content when it contains a +%Content-Length header field (Section 8.6 of [HTTP]) is malformed if the value +%of the Content-Length header field does not equal the sum of the DATA frame +%lengths received. A response that is defined as never having content, even +%when a Content-Length is present, can have a non-zero Content-Length header +%field even though no content is included in DATA frames. +% +%For malformed requests, a server MAY send an HTTP response indicating the +%error prior to closing or resetting the stream. +%% @todo All the malformed tests + +headers_reject_uppercase_header_name(Config) -> + doc("Requests containing uppercase header names must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"I-AM-GIGANTIC">>, <<"How's the weather up there?">>} + ). + +%% 4.2. HTTP Fields +%% An endpoint MUST NOT generate an HTTP/3 field section containing +%connection-specific fields; any message containing connection-specific fields +%MUST be treated as malformed. + +reject_connection_header(Config) -> + doc("Requests containing a connection header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"connection">>, <<"close">>} + ). + +reject_keep_alive_header(Config) -> + doc("Requests containing a keep-alive header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"keep-alive">>, <<"timeout=5, max=1000">>} + ). + +reject_proxy_authenticate_header(Config) -> + doc("Requests containing a proxy-authenticate header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"proxy-authenticate">>, <<"Basic">>} + ). + +reject_proxy_authorization_header(Config) -> + doc("Requests containing a proxy-authorization header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"proxy-authorization">>, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>} + ). + +reject_transfer_encoding_header(Config) -> + doc("Requests containing a transfer-encoding header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"transfer-encoding">>, <<"chunked">>} + ). + +reject_upgrade_header(Config) -> + doc("Requests containing an upgrade header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"upgrade">>, <<"websocket">>} + ). + +accept_te_header_value_trailers(Config) -> + doc("Requests containing a TE header with a value of \"trailers\" " + "must be accepted. (RFC9114 4.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>}, + {<<"te">>, <<"trailers">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +reject_te_header_other_values(Config) -> + doc("Requests containing a TE header with a value other than \"trailers\" must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"te">>, <<"trailers, deflate;q=0.5">>} + ). + +%% @todo response_dont_send_header_in_connection +%% @todo response_dont_send_connection_header +%% @todo response_dont_send_keep_alive_header +%% @todo response_dont_send_proxy_connection_header +%% @todo response_dont_send_transfer_encoding_header +%% @todo response_dont_send_upgrade_header + +%% 4.2.1. Field Compression +%% To allow for better compression efficiency, the Cookie header field +%([COOKIES]) MAY be split into separate field lines, each with one or more +%cookie-pairs, before compression. If a decompressed field section contains +%multiple cookie field lines, these MUST be concatenated into a single byte +%string using the two-byte delimiter of "; " (ASCII 0x3b, 0x20) before being +%passed into a context other than HTTP/2 or HTTP/3, such as an HTTP/1.1 +%connection, or a generic HTTP server application. + +%% 4.2.2. Header Size Constraints +%% An HTTP/3 implementation MAY impose a limit on the maximum size of the +%message header it will accept on an individual HTTP message. A server that +%receives a larger header section than it is willing to handle can send an HTTP +%431 (Request Header Fields Too Large) status code ([RFC6585]). The size of a +%field list is calculated based on the uncompressed size of fields, including +%the length of the name and value in bytes plus an overhead of 32 bytes for +%each field. +%% If an implementation wishes to advise its peer of this limit, it can be +%conveyed as a number of bytes in the SETTINGS_MAX_FIELD_SECTION_SIZE +%parameter. + +reject_unknown_pseudo_headers(Config) -> + doc("Requests containing unknown pseudo-headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<":upgrade">>, <<"websocket">>} + ). + +reject_response_pseudo_headers(Config) -> + doc("Requests containing response pseudo-headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<":status">>, <<"200">>} + ). + +reject_pseudo_headers_in_trailers(Config) -> + doc("Requests containing pseudo-headers in trailers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"trailer">>, <<"x-checksum">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"x-checksum">>, <<"md5:4cc909a007407f3706399b6496babec3">>}, + {<<":path">>, <<"/">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(10000), + <<0:10000/unit:8>>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +reject_pseudo_headers_after_regular_headers(Config) -> + doc("Requests containing pseudo-headers after regular headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<"content-length">>, <<"0">>}, + {<<":path">>, <<"/">>} + ]). + +reject_userinfo(Config) -> + doc("An authority containing a userinfo component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"user@localhost">>}, + {<<":path">>, <<"/">>} + ]). + +%% To ensure that the HTTP/1.1 request line can be reproduced accurately, this +%% pseudo-header field (:authority) MUST be omitted when translating from an +%% HTTP/1.1 request that has a request target in a method-specific form; +%% see Section 7.1 of [HTTP]. + +reject_empty_path(Config) -> + doc("A request containing an empty path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<>>} + ]). + +reject_missing_pseudo_header_method(Config) -> + doc("A request without a method component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_many_pseudo_header_method(Config) -> + doc("A request containing more than one method component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_scheme(Config) -> + doc("A request without a scheme component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_many_pseudo_header_scheme(Config) -> + doc("A request containing more than one scheme component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_authority(Config) -> + doc("A request without an authority or host component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":path">>, <<"/">>} + ]). + +accept_host_header_on_missing_pseudo_header_authority(Config) -> + doc("A request without an authority but with a host header must be accepted. " + "(RFC9114 4.3.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/">>}, + {<<"host">>, <<"localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% @todo +%% If the :scheme pseudo-header field identifies a scheme that has a mandatory +%% authority component (including "http" and "https"), the request MUST contain +%% either an :authority pseudo-header field or a Host header field. +%% - If both fields are present, they MUST NOT be empty. +%% - If both fields are present, they MUST contain the same value. + +reject_many_pseudo_header_authority(Config) -> + doc("A request containing more than one authority component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_path(Config) -> + doc("A request without a path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>} + ]). + +reject_many_pseudo_header_path(Config) -> + doc("A request containing more than one path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<":path">>, <<"/">>} + ]). + +do_reject_malformed_header(Config, Header) -> + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + Header + ]). + +do_reject_malformed_headers(Config, Headers) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} + = cow_qpack:encode_field_section(Headers, 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% For responses, a single ":status" pseudo-header field is defined that +%% carries the HTTP status code; see Section 15 of [HTTP]. This pseudo-header +%% field MUST be included in all responses; otherwise, the response is malformed +%% (see Section 4.1.2). + +%% @todo Implement CONNECT. (RFC9114 4.4. The CONNECT Method) + +%% @todo Maybe block the sending of 101 responses? (RFC9114 4.5. HTTP Upgrade) - also HTTP/2. + +%% @todo Implement server push (RFC9114 4.6. Server Push) + +%% @todo - need a way to list connections +%% 5.2. Connection Shutdown +%% Endpoints initiate the graceful shutdown of an HTTP/3 connection by sending +%% a GOAWAY frame. The GOAWAY frame contains an identifier that indicates to the +%% receiver the range of requests or pushes that were or might be processed in +%% this connection. The server sends a client-initiated bidirectional stream ID; +%% the client sends a push ID. Requests or pushes with the indicated identifier +%% or greater are rejected (Section 4.1.1) by the sender of the GOAWAY. This +%% identifier MAY be zero if no requests or pushes were processed. + +%% @todo +%% Upon sending a GOAWAY frame, the endpoint SHOULD explicitly cancel (see +%% Sections 4.1.1 and 7.2.3) any requests or pushes that have identifiers greater +%% than or equal to the one indicated, in order to clean up transport state for +%% the affected streams. The endpoint SHOULD continue to do so as more requests +%% or pushes arrive. + +%% @todo +%% Endpoints MUST NOT initiate new requests or promise new pushes on the +%% connection after receipt of a GOAWAY frame from the peer. + +%% @todo +%% Requests on stream IDs less than the stream ID in a GOAWAY frame from the +%% server might have been processed; their status cannot be known until a +%% response is received, the stream is reset individually, another GOAWAY is +%% received with a lower stream ID than that of the request in question, or the +%% connection terminates. + +%% @todo +%% Servers MAY reject individual requests on streams below the indicated ID if +%% these requests were not processed. + +%% @todo +%% If a server receives a GOAWAY frame after having promised pushes with a push +%% ID greater than or equal to the identifier contained in the GOAWAY frame, +%% those pushes will not be accepted. + +%% @todo +%% Servers SHOULD send a GOAWAY frame when the closing of a connection is known +%% in advance, even if the advance notice is small, so that the remote peer can +%% know whether or not a request has been partially processed. + +%% @todo +%% An endpoint MAY send multiple GOAWAY frames indicating different +%% identifiers, but the identifier in each frame MUST NOT be greater than the +%% identifier in any previous frame, since clients might already have retried +%% unprocessed requests on another HTTP connection. Receiving a GOAWAY containing +%% a larger identifier than previously received MUST be treated as a connection +%% error of type H3_ID_ERROR. + +%% @todo +%% An endpoint that is attempting to gracefully shut down a connection can send +%% a GOAWAY frame with a value set to the maximum possible value (2^62-4 for +%% servers, 2^62-1 for clients). + +%% @todo +%% Even when a GOAWAY indicates that a given request or push will not be +%% processed or accepted upon receipt, the underlying transport resources still +%% exist. The endpoint that initiated these requests can cancel them to clean up +%% transport state. + +%% @todo +%% Once all accepted requests and pushes have been processed, the endpoint can +%% permit the connection to become idle, or it MAY initiate an immediate closure +%% of the connection. An endpoint that completes a graceful shutdown SHOULD use +%% the H3_NO_ERROR error code when closing the connection. + +%% @todo +%% If a client has consumed all available bidirectional stream IDs with +%% requests, the server need not send a GOAWAY frame, since the client is unable +%% to make further requests. @todo OK that one's some weird stuff lol + +%% @todo +%% 5.3. Immediate Application Closure +%% Before closing the connection, a GOAWAY frame MAY be sent to allow the +%% client to retry some requests. Including the GOAWAY frame in the same packet +%% as the QUIC CONNECTION_CLOSE frame improves the chances of the frame being +%% received by clients. + +bidi_allow_at_least_a_hundred(Config) -> + doc("Endpoints must allow the peer to create at least " + "one hundred bidirectional streams. (RFC9114 6.1"), + #{conn := Conn} = do_connect(Config), + receive + {quic, streams_available, Conn, #{bidi_streams := NumStreams}} -> + true = NumStreams >= 100, + ok + after 5000 -> + error(timeout) + end. + +unidi_allow_at_least_three(Config) -> + doc("Endpoints must allow the peer to create at least " + "three unidirectional streams. (RFC9114 6.2"), + #{conn := Conn} = do_connect(Config), + %% Confirm that the server advertised support for at least 3 unidi streams. + receive + {quic, streams_available, Conn, #{unidi_streams := NumStreams}} -> + true = NumStreams >= 3, + ok + after 5000 -> + error(timeout) + end, + %% Confirm that we can create the unidi streams. + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [<<0>>, SettingsBin]), + {ok, EncoderRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(EncoderRef, <<2>>), + {ok, DecoderRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(DecoderRef, <<3>>), + %% Streams shouldn't get closed. + fun Loop() -> + receive + %% We don't care about these messages. + {quic, dgram_state_changed, Conn, _} -> + Loop(); + {quic, peer_needs_streams, Conn, _} -> + Loop(); + %% Any other we do care. + Msg -> + error(Msg) + after 1000 -> + ok + end + end(). + +unidi_create_critical_first(Config) -> + doc("Endpoints should create the HTTP control stream as well as " + "the QPACK encoder and decoder streams first. (RFC9114 6.2"), + %% The control stream is accepted in the do_connect/1 function. + #{conn := Conn} = do_connect(Config, #{peer_unidi_stream_count => 3}), + Unidi1 = do_accept_qpack_stream(Conn), + Unidi2 = do_accept_qpack_stream(Conn), + case {Unidi1, Unidi2} of + {{encoder, _}, {decoder, _}} -> + ok; + {{decoder, _}, {encoder, _}} -> + ok + end. + +do_accept_qpack_stream(Conn) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, <>, StreamRef, _} -> + {case Type of + 2 -> encoder; + 3 -> decoder + end, StreamRef} + after 5000 -> + error(timeout) + end + after 5000 -> + error(timeout) + end. + +%% @todo We should also confirm that there's at least 1,024 bytes of +%% flow-control credit for each unidi stream the server creates. (How?) +%% It can be set via stream_recv_window_default in quicer. + +unidi_abort_unknown_type(Config) -> + doc("Receipt of an unknown stream type must be aborted " + "with an H3_STREAM_CREATION_ERROR stream error. (RFC9114 6.2, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + %% Create an unknown unidirectional stream. + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(1 + do_reserved_type()), + rand:bytes(rand:uniform(4096)) + ]), + %% The stream should have been aborted. + #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef), + ok. + +unidi_abort_reserved_type(Config) -> + doc("Receipt of a reserved stream type must be aborted " + "with an H3_STREAM_CREATION_ERROR stream error. " + "(RFC9114 6.2, RFC9114 6.2.3, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + %% Create a reserved unidirectional stream. + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_reserved_type()), + rand:bytes(rand:uniform(4096)) + ]), + %% The stream should have been aborted. + #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% As certain stream types can affect connection state, a recipient SHOULD NOT +%% discard data from incoming unidirectional streams prior to reading the stream type. + +%% Implementations MAY send stream types before knowing whether the peer +%supports them. However, stream types that could modify the state or semantics +%of existing protocol components, including QPACK or other extensions, MUST NOT +%be sent until the peer is known to support them. +%% @todo It may make sense for Cowboy to delay the creation of unidi streams +%% a little in order to save resources. We could create them when the +%% client does as well, or something similar. + +%% A receiver MUST tolerate unidirectional streams being closed or reset prior +%% to the reception of the unidirectional stream header. + +%% Each side MUST initiate a single control stream at the beginning of the +%% connection and send its SETTINGS frame as the first frame on this stream. +%% @todo What to do when the client never opens a control stream? +%% @todo Similarly, a stream could be opened but with no data being sent. +%% @todo Similarly, a control stream could be opened with no SETTINGS frame sent. + +control_reject_first_frame_data(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<0>>, %% DATA frame. + cow_http3:encode_int(12), + <<"Hello world!">> + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_headers(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_cancel_push(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<3>>, %% CANCEL_PUSH frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_accept_first_frame_settings(Config) -> + doc("The first frame on a control stream " + "must be a SETTINGS frame. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +control_reject_first_frame_push_promise(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<5>>, %% PUSH_PROMISE frame. + cow_http3:encode_int(iolist_size(EncodedHeaders) + 1), + cow_http3:encode_int(0), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_goaway(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<7>>, %% GOAWAY frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_max_push_id(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<13>>, %% MAX_PUSH_ID frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_reserved(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + cow_http3:encode_int(do_reserved_type()), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_multiple(Config) -> + doc("Endpoints must not create multiple control streams. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_reject_multiple(Config, [<<0>>, SettingsBin]). + +do_critical_reject_multiple(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + %% Create two critical streams. + {ok, StreamRef1} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef1, HeaderData), + {ok, StreamRef2} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef2, HeaderData), + %% The connection should have been closed. + #{reason := h3_stream_creation_error} = do_wait_connection_closed(Conn), + ok. + +control_local_closed_abort(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_local_closed_abort(Config, [<<0>>, SettingsBin]). + +do_critical_local_closed_abort(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, HeaderData), + %% Wait a little to make sure the stream data was received before we abort. + timer:sleep(100), + %% Close the critical stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + timer:sleep(1000), + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +control_local_closed_graceful(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_local_closed_graceful(Config, [<<0>>, SettingsBin]). + +do_critical_local_closed_graceful(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, HeaderData), + %% Close the critical stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +control_remote_closed_abort(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + #{conn := Conn, control := ControlRef} = do_connect(Config), + %% Close the control stream. + quicer:async_shutdown_stream(ControlRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +%% We cannot gracefully shutdown a remote unidi stream; only abort reading. + +%% Because the contents of the control stream are used to manage the behavior +%% of other streams, endpoints SHOULD provide enough flow-control credit to keep +%% the peer's control stream from becoming blocked. + +%% @todo Implement server push (RFC9114 6.2.2 Push Streams) + +data_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello ">> + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, [ + <<"server!">> + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello server!">> + } = do_receive_response(StreamRef), + ok. + +headers_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + Half = iolist_size(EncodedHeaders) div 2, + <> + = iolist_to_binary(EncodedHeaders), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeadersPart1 + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, [ + EncodedHeadersPart2 + ]), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% @todo Implement server push. cancel_push_frame_can_span_multiple_packets(Config) -> + +settings_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + <> = SettingsBin, + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsPart1 + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + SettingsPart2 + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +goaway_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(1) %% GOAWAY part 1. + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + cow_http3:encode_int(0) %% GOAWAY part 2. + ]), + %% The connection should be closed gracefully. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + h3_no_error = cow_http3:code_to_error(Code), + ok; + %% @todo Temporarily also accept this message. I am + %% not sure why it happens but it isn't wrong per se. + {quic, shutdown, Conn, success} -> + ok + after 1000 -> + error(timeout) + end. + +max_push_id_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(1) %% MAX_PUSH_ID part 1. + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + cow_http3:encode_int(0) %% MAX_PUSH_ID part 2. + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +unknown_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_unknown_frame_type()), + cow_http3:encode_int(16383) + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4095)), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% The DATA and SETTINGS frames can be zero-length therefore +%% they cannot be too short. + +headers_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. cancel_push_frame_too_short(Config) -> + +goaway_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(0) %% GOAWAY. + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +max_push_id_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(0) %% MAX_PUSH_ID. + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +data_frame_truncated(Config) -> + doc("Truncated frames must be rejected with an " + "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello ">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +headers_frame_truncated(Config) -> + doc("Truncated frames must be rejected with an " + "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)) + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% I am not sure how to test truncated CANCEL_PUSH, SETTINGS, GOAWAY +%% or MAX_PUSH_ID frames, as those are sent on the control stream, +%% which we cannot terminate. + +%% The DATA, HEADERS and SETTINGS frames can be of any length +%% therefore they cannot be too long per se, even if unwanted +%% data can be included at the end of the frame's payload. + +%% @todo Implement server push. cancel_push_frame_too_long(Config) -> + +goaway_frame_too_long(Config) -> + doc("Frames that contain additional bytes after the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(3), %% GOAWAY. + <<0, 1, 2>> + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +max_push_id_frame_too_long(Config) -> + doc("Frames that contain additional bytes after the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(9), %% MAX_PUSH_ID. + <<0, 1, 2, 3, 4, 5, 6, 7, 8>> + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% Streams may terminate abruptly in the middle of frames. + +data_frame_rejected_on_control_stream(Config) -> + doc("DATA frames received on the control stream must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<0>>, %% DATA frame. + cow_http3:encode_int(12), + <<"Hello world!">> + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_frame_rejected_on_control_stream(Config) -> + doc("HEADERS frames received on the control stream must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.2)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (RFC9114 7.2.3. CANCEL_PUSH) + +settings_twice(Config) -> + doc("Receipt of a second SETTINGS frame on the control stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + SettingsBin + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +settings_on_bidi_stream(Config) -> + doc("Receipt of a SETTINGS frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + SettingsBin, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +settings_identifier_twice(Config) -> + doc("Receipt of a duplicate SETTINGS identifier must be rejected " + "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(6), cow_http3:encode_int(4096), + cow_http3:encode_int(6), cow_http3:encode_int(8192) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should have been closed. + #{reason := h3_settings_error} = do_wait_connection_closed(Conn), + ok. + +settings_ignore_unknown_identifier(Config) -> + doc("Unknown SETTINGS identifiers must be ignored (RFC9114 7.2.4, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(999), cow_http3:encode_int(4096) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +settings_ignore_reserved_identifier(Config) -> + doc("Reserved SETTINGS identifiers must be ignored (RFC9114 7.2.4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(do_reserved_type()), cow_http3:encode_int(4096) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +%% @todo Check that we send a reserved SETTINGS identifier when sending a +%% non-empty SETTINGS frame. (7.2.4.1. Defined SETTINGS Parameters) + +%% @todo Check that setting SETTINGS_MAX_FIELD_SECTION_SIZE works. + +%% It is unclear whether the SETTINGS identifier 0x00 must be rejected or ignored. + +settings_reject_http2_0x02(Config) -> + do_settings_reject_http2(Config, 2, 1). + +settings_reject_http2_0x03(Config) -> + do_settings_reject_http2(Config, 3, 100). + +settings_reject_http2_0x04(Config) -> + do_settings_reject_http2(Config, 4, 128000). + +settings_reject_http2_0x05(Config) -> + do_settings_reject_http2(Config, 5, 1000000). + +do_settings_reject_http2(Config, Identifier, Value) -> + doc("Receipt of an unused HTTP/2 SETTINGS identifier must be rejected " + "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4, RFC9114 11.2.2)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(Identifier), cow_http3:encode_int(Value) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should have been closed. + #{reason := h3_settings_error} = do_wait_connection_closed(Conn), + ok. + +%% 7.2.4.2. Initialization +%% An HTTP implementation MUST NOT send frames or requests that would be +%% invalid based on its current understanding of the peer's settings. +%% @todo In the case of SETTINGS_MAX_FIELD_SECTION_SIZE I don't think we have a choice. + +%% All settings begin at an initial value. Each endpoint SHOULD use these +%% initial values to send messages before the peer's SETTINGS frame has arrived, +%% as packets carrying the settings can be lost or delayed. When the SETTINGS +%% frame arrives, any settings are changed to their new values. + +%% Endpoints MUST NOT require any data to be received from the peer prior to +%% sending the SETTINGS frame; settings MUST be sent as soon as the transport is +%% ready to send data. + +%% @todo Implement 0-RTT. (7.2.4.2. Initialization) + +%% @todo Implement server push. (7.2.5. PUSH_PROMISE) + +goaway_on_bidi_stream(Config) -> + doc("Receipt of a GOAWAY frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.6)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<7>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% GOAWAY. + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (7.2.6 GOAWAY - will have to reject too large push IDs) + +max_push_id_on_bidi_stream(Config) -> + doc("Receipt of a MAX_PUSH_ID frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% MAX_PUSH_ID. + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (7.2.7 MAX_PUSH_ID) + +max_push_id_reject_lower(Config) -> + doc("Receipt of a MAX_PUSH_ID value lower than previously received " + "must be rejected with an H3_ID_ERROR connection error. (RFC9114 7.2.7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(20), %% MAX_PUSH_ID. + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(10) %% MAX_PUSH_ID. + ]), + %% The connection should have been closed. + #{reason := h3_id_error} = do_wait_connection_closed(Conn), + ok. + +reserved_on_control_stream(Config) -> + doc("Receipt of a reserved frame type on a control stream " + "must be ignored. (RFC9114 7.2.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + cow_http3:encode_int(do_reserved_type()), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +reserved_reject_http2_0x02_control(Config) -> + do_reserved_reject_http2_control(Config, 2). + +reserved_reject_http2_0x06_control(Config) -> + do_reserved_reject_http2_control(Config, 6). + +reserved_reject_http2_0x08_control(Config) -> + do_reserved_reject_http2_control(Config, 8). + +reserved_reject_http2_0x09_control(Config) -> + do_reserved_reject_http2_control(Config, 9). + +do_reserved_reject_http2_control(Config, Type) -> + doc("Receipt of an unused HTTP/2 frame type must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + cow_http3:encode_int(Type), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +reserved_reject_http2_0x02_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 2). + +reserved_reject_http2_0x06_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 6). + +reserved_reject_http2_0x08_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 8). + +reserved_reject_http2_0x09_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 9). + +do_reserved_reject_http2_bidi(Config, Type) -> + doc("Receipt of an unused HTTP/2 frame type must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + Len = rand:uniform(512), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(Type), + cow_http3:encode_int(Len), + rand:bytes(Len), + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% An endpoint MAY choose to treat a stream error as a connection error under +%% certain circumstances, closing the entire connection in response to a +%% condition on a single stream. + +%% Because new error codes can be defined without negotiation (see Section 9), +%% use of an error code in an unexpected context or receipt of an unknown error +%% code MUST be treated as equivalent to H3_NO_ERROR. + +%% 8.1. HTTP/3 Error Codes +%% H3_INTERNAL_ERROR (0x0102): An internal error has occurred in the HTTP stack. +%% H3_EXCESSIVE_LOAD (0x0107): The endpoint detected that its peer is +%% exhibiting a behavior that might be generating excessive load. +%% H3_MISSING_SETTINGS (0x010a): No SETTINGS frame was received +%% at the beginning of the control stream. +%% H3_REQUEST_REJECTED (0x010b): A server rejected a request without +%% performing any application processing. +%% H3_REQUEST_CANCELLED (0x010c): The request or its response +%% (including pushed response) is cancelled. +%% H3_REQUEST_INCOMPLETE (0x010d): The client's stream terminated +%% without containing a fully formed request. +%% H3_CONNECT_ERROR (0x010f): The TCP connection established in +%% response to a CONNECT request was reset or abnormally closed. +%% H3_VERSION_FALLBACK (0x0110): The requested operation cannot +%% be served over HTTP/3. The peer should retry over HTTP/1.1. + +%% 9. Extensions to HTTP/3 +%% If a setting is used for extension negotiation, the default value MUST be +%% defined in such a fashion that the extension is disabled if the setting is +%% omitted. + +%% 10. Security Considerations +%% 10.3. Intermediary-Encapsulation Attacks +%% Requests or responses containing invalid field names MUST be treated as malformed. +%% Any request or response that contains a character not permitted in a field +%% value MUST be treated as malformed. + +%% 10.5. Denial-of-Service Considerations +%% Implementations SHOULD track the use of these features and set limits on +%% their use. An endpoint MAY treat activity that is suspicious as a connection +%% error of type H3_EXCESSIVE_LOAD, but false positives will result in disrupting +%% valid connections and requests. + +reject_large_unknown_frame(Config) -> + doc("Large unknown frames may risk denial-of-service " + "and should be rejected. (RFC9114 10.5)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_unknown_frame_type()), + cow_http3:encode_int(16385) + ]), + #{reason := h3_excessive_load} = do_wait_connection_closed(Conn), + ok. + +%% 10.5.1. Limits on Field Section Size +%% An endpoint can use the SETTINGS_MAX_FIELD_SECTION_SIZE (Section 4.2.2) +%% setting to advise peers of limits that might apply on the size of field +%% sections. +%% +%% A server that receives a larger field section than it is willing to handle +%% can send an HTTP 431 (Request Header Fields Too Large) status code +%% ([RFC6585]). + +%% 10.6. Use of Compression +%% Implementations communicating on a secure channel MUST NOT compress content +%% that includes both confidential and attacker-controlled data unless separate +%% compression contexts are used for each source of data. Compression MUST NOT be +%% used if the source of data cannot be reliably determined. + +%% 10.9. Early Data +%% The anti-replay mitigations in [HTTP-REPLAY] MUST be applied when using HTTP/3 with 0-RTT. + +%% 10.10. Migration +%% Certain HTTP implementations use the client address for logging or +%% access-control purposes. Since a QUIC client's address might change during a +%% connection (and future versions might support simultaneous use of multiple +%% addresses), such implementations will need to either actively retrieve the +%% client's current address or addresses when they are relevant or explicitly +%% accept that the original address might change. @todo Document this behavior. + +%% Appendix A. Considerations for Transitioning from HTTP/2 +%% A.1. Streams +%% QUIC considers a stream closed when all data has been received and sent data +%% has been acknowledged by the peer. HTTP/2 considers a stream closed when the +%% frame containing the END_STREAM bit has been committed to the transport. As a +%% result, the stream for an equivalent exchange could remain "active" for a +%% longer period of time. HTTP/3 servers might choose to permit a larger number +%% of concurrent client-initiated bidirectional streams to achieve equivalent +%% concurrency to HTTP/2, depending on the expected usage patterns. @todo Document this. + +%% Helper functions. + +%% @todo Maybe have a function in cow_http3. +do_reserved_type() -> + 16#1f * (rand:uniform(148764065110560900) - 1) + 16#21. + +do_connect(Config) -> + do_connect(Config, #{}). + +do_connect(Config, Opts) -> + {ok, Conn} = quicer:connect("localhost", config(port, Config), + Opts#{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, Settings} = do_wait_settings(Conn), + #{ + conn => Conn, + control => ControlRef, %% This is the peer control stream. + settings => Settings + }. + +do_wait_settings(Conn) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, << + 0, %% Control stream. + SettingsFrame/bits + >>, StreamRef, _} -> + {ok, {settings, Settings}, <<>>} = cow_http3:parse(SettingsFrame), + {ok, StreamRef, Settings} + after 5000 -> + {error, timeout} + end + after 5000 -> + {error, timeout} + end. + +do_receive_data(StreamRef) -> + receive + {quic, Data, StreamRef, _Flags} when is_binary(Data) -> + {ok, Data} + after 5000 -> + {error, timeout} + end. + +do_guess_int_encoding(Data) -> + SizeWithLen = byte_size(Data) - 1, + if + SizeWithLen < 64 + 1 -> + {0, 6}; + SizeWithLen < 16384 + 2 -> + {1, 14}; + SizeWithLen < 1073741824 + 4 -> + {2, 30}; + SizeWithLen < 4611686018427387904 + 8 -> + {3, 62} + end. + +do_wait_peer_send_shutdown(StreamRef) -> + receive + {quic, peer_send_shutdown, StreamRef, undefined} -> + ok + after 5000 -> + {error, timeout} + end. + +do_wait_stream_aborted(StreamRef) -> + receive + {quic, peer_send_aborted, StreamRef, Code} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason}; + {quic, peer_receive_aborted, StreamRef, Code} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason} + after 5000 -> + {error, timeout} + end. + +do_wait_stream_closed(StreamRef) -> + receive + {quic, stream_closed, StreamRef, #{error := Error, is_conn_shutdown := false}} -> + 0 = Error, + ok + after 5000 -> + {error, timeout} + end. + +do_receive_response(StreamRef) -> + {ok, Data} = do_receive_data(StreamRef), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + Rest/bits + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + Headers = maps:from_list(DecodedResponse), + #{<<"content-length">> := BodyLen} = Headers, + {DLenEnc, DLenBits} = do_guess_int_encoding(Rest), + Body = case Rest of + <<>> -> + <<>>; + << + 0, %% DATA frame. + DLenEnc:2, DLen:DLenBits, + Body0:DLen/bytes + >> -> + BodyLen = integer_to_binary(byte_size(Body0)), + Body0 + end, + ok = do_wait_peer_send_shutdown(StreamRef), + #{ + headers => Headers, + body => Body + }. + +do_wait_connection_closed(Conn) -> + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason} + after 5000 -> + {error, timeout} + end. + +-endif. diff --git a/test/rfc9114_SUITE_data/client.key b/test/rfc9114_SUITE_data/client.key new file mode 100644 index 00000000..9c5e1ced --- /dev/null +++ b/test/rfc9114_SUITE_data/client.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVJakPYfQA1Hr6Gnq +GYmpMfXpxUi2QwDBrZfw8dBcVqKhRANCAAQDHeeAvjwD7p+Mg1F+G9FBNy+7Wcms +HEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx0l77vFjrUc6X1al4+ZM5 +-----END PRIVATE KEY----- diff --git a/test/rfc9114_SUITE_data/client.pem b/test/rfc9114_SUITE_data/client.pem new file mode 100644 index 00000000..cd9dc8cb --- /dev/null +++ b/test/rfc9114_SUITE_data/client.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuswCgYIKoZIzj0EAwIw +IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy +MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E +WUFCMQ8wDQYDVQQDDAZjbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQD +HeeAvjwD7p+Mg1F+G9FBNy+7WcmsHEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx +0l77vFjrUc6X1al4+ZM5o2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGY2xp +ZW50MB0GA1UdDgQWBBTnhPpO+rSIFAxvkwVjlkKOO2jOeDAfBgNVHSMEGDAWgBSD +Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEA2qf29EBp2hcL +sEO7MM0ZLm4gnaMdcxtyneF3+c7Lg3cCIBFTVP8xHlhCJyb8ESV7S052VU0bKQFN +ioyoYtcycxuZ +-----END CERTIFICATE----- diff --git a/test/rfc9114_SUITE_data/server.key b/test/rfc9114_SUITE_data/server.key new file mode 100644 index 00000000..45ea890b --- /dev/null +++ b/test/rfc9114_SUITE_data/server.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvykUYMOS2gW8XTTh +HgmeJM36NT8GGTNXzzt4sIs0o9ahRANCAATnQOMkKbLFQCZY/cxf8otEJG2tVuG6 +QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBMB4ZfLA/PdSiO+KrOeOcj +-----END PRIVATE KEY----- diff --git a/test/rfc9114_SUITE_data/server.pem b/test/rfc9114_SUITE_data/server.pem new file mode 100644 index 00000000..43cce8e3 --- /dev/null +++ b/test/rfc9114_SUITE_data/server.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuowCgYIKoZIzj0EAwIw +IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy +MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E +WUFCMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATn +QOMkKbLFQCZY/cxf8otEJG2tVuG6QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBM +B4ZfLA/PdSiO+KrOeOcjo2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGc2Vy +dmVyMB0GA1UdDgQWBBS+Np5J8BtmWU534pm9hqhrG/EQ7zAfBgNVHSMEGDAWgBSD +Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEApRfjIEJfO1VH +ETgNG3/MzDayYScPocVn4v8U15ygEw8CIFUY3xMZzJ5AmiRe9PhIUgueOKQNMtds +wdF9+097+Ey0 +-----END CERTIFICATE----- diff --git a/test/rfc9204_SUITE.erl b/test/rfc9204_SUITE.erl new file mode 100644 index 00000000..e8defd2a --- /dev/null +++ b/test/rfc9204_SUITE.erl @@ -0,0 +1,357 @@ +%% Copyright (c) 2024, Loïc Hoguin +%% +%% 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(rfc9204_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +-ifdef(COWBOY_QUICER). + +-include_lib("quicer/include/quicer.hrl"). + +all() -> + [{group, h3}]. + +groups() -> + %% @todo Enable parallel tests but for this issues in the + %% QUIC accept loop need to be figured out (can't connect + %% concurrently somehow, no backlog?). + [{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, []} + ]} +]. + +%% Encoder. + +%% 2.1 +%% QPACK preserves the ordering of field lines within +%% each field section. An encoder MUST emit field +%% representations in the order they appear in the +%% input field section. + +%% 2.1.1 +%% If the dynamic table does not contain enough room +%% for a new entry without evicting other entries, +%% and the entries that would be evicted are not evictable, +%% the encoder MUST NOT insert that entry into the dynamic +%% table (including duplicates of existing entries). +%% In order to avoid this, an encoder that uses the +%% dynamic table has to keep track of each dynamic +%% table entry referenced by each field section until +%% those representations are acknowledged by the decoder; +%% see Section 4.4.1. + +%% 2.1.2 +%% The decoder specifies an upper bound on the number +%% of streams that can be blocked using the +%% SETTINGS_QPACK_BLOCKED_STREAMS setting; see Section 5. +%% An encoder MUST limit the number of streams that could +%% become blocked to the value of SETTINGS_QPACK_BLOCKED_STREAMS +%% at all times. If a decoder encounters more blocked streams +%% than it promised to support, it MUST treat this as a +%% connection error of type QPACK_DECOMPRESSION_FAILED. + +%% 2.1.3 +%% To avoid these deadlocks, an encoder SHOULD NOT +%% write an instruction unless sufficient stream and +%% connection flow-control credit is available for +%% the entire instruction. + +%% Decoder. + +%% 2.2 +%% The decoder MUST emit field lines in the order their +%% representations appear in the encoded field section. + +%% 2.2.1 +%% While blocked, encoded field section data SHOULD +%% remain in the blocked stream's flow-control window. + +%% If it encounters a Required Insert Count smaller than +%% expected, it MUST treat this as a connection error of +%% type QPACK_DECOMPRESSION_FAILED; see Section 2.2.3. + +%% If it encounters a Required Insert Count larger than +%% expected, it MAY treat this as a connection error of +%% type QPACK_DECOMPRESSION_FAILED. + +%% After the decoder finishes decoding a field section +%% encoded using representations containing dynamic table +%% references, it MUST emit a Section Acknowledgment +%% instruction (Section 4.4.1). + +%% 2.2.2.2 +%% A decoder with a maximum dynamic table capacity +%% (Section 3.2.3) equal to zero MAY omit sending Stream +%% Cancellations, because the encoder cannot have any +%% dynamic table references. + +%% 2.2.3 +%% If the decoder encounters a reference in a field line +%% representation to a dynamic table entry that has already +%% been evicted or that has an absolute index greater than +%% or equal to the declared Required Insert Count (Section 4.5.1), +%% it MUST treat this as a connection error of type +%% QPACK_DECOMPRESSION_FAILED. + +%% If the decoder encounters a reference in an encoder +%% instruction to a dynamic table entry that has already +%% been evicted, it MUST treat this as a connection error +%% of type QPACK_ENCODER_STREAM_ERROR. + +%% Static table. + +%% 3.1 +%% When the decoder encounters an invalid static table index +%% in a field line representation, it MUST treat this as a +%% connection error of type QPACK_DECOMPRESSION_FAILED. +%% +%% If this index is received on the encoder stream, this +%% MUST be treated as a connection error of type +%% QPACK_ENCODER_STREAM_ERROR. + +%% Dynamic table. + +%% 3.2 +%% The dynamic table can contain duplicate entries +%% (i.e., entries with the same name and same value). +%% Therefore, duplicate entries MUST NOT be treated +%% as an error by the decoder. + +%% 3.2.2 +%% The encoder MUST NOT cause a dynamic table entry to be +%% evicted unless that entry is evictable; see Section 2.1.1. + +%% It is an error if the encoder attempts to add an entry +%% that is larger than the dynamic table capacity; the +%% decoder MUST treat this as a connection error of type +%% QPACK_ENCODER_STREAM_ERROR. + +%% 3.2.3 +%% The encoder MUST NOT set a dynamic table capacity that +%% exceeds this maximum, but it can choose to use a lower +%% dynamic table capacity; see Section 4.3.1. + +%% When the client's 0-RTT value of the SETTING is zero, +%% the server MAY set it to a non-zero value in its SETTINGS +%% frame. If the remembered value is non-zero, the server +%% MUST send the same non-zero value in its SETTINGS frame. +%% If it specifies any other value, or omits +%% SETTINGS_QPACK_MAX_TABLE_CAPACITY from SETTINGS, +%% the encoder must treat this as a connection error of +%% type QPACK_DECODER_STREAM_ERROR. + +%% When the maximum table capacity is zero, the encoder +%% MUST NOT insert entries into the dynamic table and +%% MUST NOT send any encoder instructions on the encoder stream. + +%% Wire format. + +%% 4.1.1 +%% QPACK implementations MUST be able to decode integers +%% up to and including 62 bits long. + +%% Encoder and decoder streams. + +decoder_reject_multiple(Config) -> + doc("Endpoints must not create multiple decoder streams. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_reject_multiple(Config, <<3>>). + +encoder_reject_multiple(Config) -> + doc("Endpoints must not create multiple encoder streams. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_reject_multiple(Config, <<2>>). + +%% 4.2 +%% The sender MUST NOT close either of these streams, +%% and the receiver MUST NOT request that the sender close +%% either of these streams. Closure of either unidirectional +%% stream type MUST be treated as a connection error of type +%% H3_CLOSED_CRITICAL_STREAM. + +decoder_local_closed_abort(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_abort(Config, <<3>>). + +decoder_local_closed_graceful(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<3>>). + +decoder_remote_closed_abort(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}), + {ok, #{decoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}), + %% Close the control stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn), + ok. + +encoder_local_closed_abort(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_abort(Config, <<2>>). + +encoder_local_closed_graceful(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<2>>). + +encoder_remote_closed_abort(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}), + {ok, #{encoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}), + %% Close the control stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn), + ok. + +do_wait_unidi_streams(_, Acc=#{decoder := _, encoder := _}) -> + {ok, Acc}; +do_wait_unidi_streams(Conn, Acc) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, <>, StreamRef, _} -> + Type = case TypeValue of + 2 -> encoder; + 3 -> decoder + end, + do_wait_unidi_streams(Conn, Acc#{Type => StreamRef}) + after 5000 -> + {error, timeout} + end + after 5000 -> + {error, timeout} + end. + +%% 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). + +%% An endpoint MAY avoid creating a decoder stream if its +%% decoder sets the maximum capacity of the dynamic table to zero. + +%% An endpoint MUST allow its peer to create an encoder stream +%% and a decoder stream even if the connection's settings +%% prevent their use. + +%% Encoder instructions. + +%% 4.3.1 +%% The new capacity MUST be lower than or equal to the limit +%% described in Section 3.2.3. In HTTP/3, this limit is the +%% value of the SETTINGS_QPACK_MAX_TABLE_CAPACITY parameter +%% (Section 5) received from the decoder. The decoder MUST +%% treat a new dynamic table capacity value that exceeds this +%% limit as a connection error of type QPACK_ENCODER_STREAM_ERROR. + +%% Reducing the dynamic table capacity can cause entries to be +%% evicted; see Section 3.2.2. This MUST NOT cause the eviction +%% of entries that are not evictable; see Section 2.1.1. + +%% Decoder instructions. + +%% 4.4.1 +%% If an encoder receives a Section Acknowledgment instruction +%% referring to a stream on which every encoded field section +%% with a non-zero Required Insert Count has already been +%% acknowledged, this MUST be treated as a connection error +%% of type QPACK_DECODER_STREAM_ERROR. + +%% 4.4.3 +%% An encoder that receives an Increment field equal to zero, +%% or one that increases the Known Received Count beyond what +%% the encoder has sent, MUST treat this as a connection error +%% of type QPACK_DECODER_STREAM_ERROR. + +%% Field line representation. + +%% 4.5.1.1 +%% If the decoder encounters a value of EncodedInsertCount that +%% could not have been produced by a conformant encoder, it MUST +%% treat this as a connection error of type QPACK_DECOMPRESSION_FAILED. + +%% 4.5.1.2 +%% The value of Base MUST NOT be negative. Though the protocol +%% might operate correctly with a negative Base using post-Base +%% indexing, it is unnecessary and inefficient. An endpoint MUST +%% treat a field block with a Sign bit of 1 as invalid if the +%% value of Required Insert Count is less than or equal to the +%% value of Delta Base. + +%% 4.5.4 +%% When the 'N' bit is set, the encoded field line MUST always +%% be encoded with a literal representation. In particular, +%% when a peer sends a field line that it received represented +%% as a literal field line with the 'N' bit set, it MUST use a +%% literal representation to forward this field line. This bit +%% is intended for protecting field values that are not to be +%% put at risk by compressing them; see Section 7.1 for more details. + +%% Configuration. + +%% 5 +%% SETTINGS_QPACK_MAX_TABLE_CAPACITY +%% SETTINGS_QPACK_BLOCKED_STREAMS + +%% Security considerations. + +%% 7.1.2 +%% (security if used as a proxy merging many connections into one) +%% An ideal solution segregates access to the dynamic table +%% based on the entity that is constructing the message. +%% Field values that are added to the table are attributed +%% to an entity, and only the entity that created a particular +%% value can extract that value. + +%% 7.1.3 +%% An intermediary MUST NOT re-encode a value that uses a +%% literal representation with the 'N' bit set with another +%% representation that would index it. If QPACK is used for +%% re-encoding, a literal representation with the 'N' bit set +%% MUST be used. If HPACK is used for re-encoding, the +%% never-indexed literal representation (see Section 6.2.3 +%% of [RFC7541]) MUST be used. + +%% 7.4 +%% An implementation has to set a limit for the values it +%% accepts for integers, as well as for the encoded length; +%% see Section 4.1.1. In the same way, it has to set a limit +%% to the length it accepts for string literals; see Section 4.1.2. +%% These limits SHOULD be large enough to process the largest +%% individual field the HTTP implementation can be configured +%% to accept. + +%% If an implementation encounters a value larger than it is +%% able to decode, this MUST be treated as a stream error of +%% type QPACK_DECOMPRESSION_FAILED if on a request stream or +%% a connection error of the appropriate type if on the +%% encoder or decoder stream. + +-endif. diff --git a/test/rfc9220_SUITE.erl b/test/rfc9220_SUITE.erl new file mode 100644 index 00000000..7f447ed8 --- /dev/null +++ b/test/rfc9220_SUITE.erl @@ -0,0 +1,485 @@ +%% Copyright (c) 2018, Loïc Hoguin +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(rfc9220_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +all() -> + [{group, enabled}]. + +groups() -> + Tests = ct_helper:all(?MODULE), + [{enabled, [], Tests}]. %% @todo Enable parallel when all is better. + +init_per_group(Name = enabled, Config) -> + cowboy_test:init_http3(Name, #{ + enable_connect_protocol => true, + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/ws", ws_echo, []} + ]} +]. + +% The SETTINGS_ENABLE_CONNECT_PROTOCOL SETTINGS Parameter. + +% The new parameter name is SETTINGS_ENABLE_CONNECT_PROTOCOL. The +% value of the parameter MUST be 0 or 1. + +% Upon receipt of SETTINGS_ENABLE_CONNECT_PROTOCOL with a value of 1 a +% client MAY use the Extended CONNECT definition of this document when +% creating new streams. Receipt of this parameter by a server does not +% have any impact. +%% @todo ignore_client_enable_setting(Config) -> + +reject_handshake_when_disabled(Config0) -> + doc("Extended CONNECT requests MUST be rejected with a " + "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. " + "(RFC9220, RFC8441 4)"), + Config = cowboy_test:init_http3(disabled, #{ + enable_connect_protocol => false, + env => #{dispatch => cowboy_router:compile(init_routes(Config0))} + }, Config0), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0. + #{ + conn := Conn, + settings := Settings + } = rfc9114_SUITE:do_connect(Config), + case Settings of + #{enable_connect_protocol := false} -> ok; + _ when map_size(Settings) =:= 0 -> ok + end, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_handshake_disabled_by_default(Config0) -> + doc("Extended CONNECT requests MUST be rejected with a " + "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. " + "(RFC9220, RFC8441 4)"), + Config = cowboy_test:init_http3(disabled, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))} + }, Config0), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0. + #{ + conn := Conn, + settings := Settings + } = rfc9114_SUITE:do_connect(Config), + case Settings of + #{enable_connect_protocol := false} -> ok; + _ when map_size(Settings) =:= 0 -> ok + end, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% The Extended CONNECT Method. + +accept_uppercase_pseudo_header_protocol(Config) -> + doc("The :protocol pseudo header is case insensitive. (RFC9220, RFC8441 4, RFC9110 7.8)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"WEBSOCKET">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef), + {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + ok. + +reject_many_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request containing more than one " + "protocol component must be rejected with a H3_MESSAGE_ERROR " + "stream error. (RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with more than one :protocol pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":protocol">>, <<"mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_unknown_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request containing more than one " + "protocol component must be rejected with a 501 Not Implemented " + "response. (RFC9220, RFC8441 4)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with an unknown protocol. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been rejected with a 501 Not Implemented. + #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef), + ok. + +reject_invalid_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request with an invalid protocol " + "component must be rejected with a 501 Not Implemented " + "response. (RFC9220, RFC8441 4)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with an invalid protocol. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been rejected with a 501 Not Implemented. + #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef), + ok. + +reject_missing_pseudo_header_scheme(Config) -> + doc("An extended CONNECT request whtout a scheme component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :scheme pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_missing_pseudo_header_path(Config) -> + doc("An extended CONNECT request whtout a path component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :path pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% On requests bearing the :protocol pseudo-header, the :authority +% pseudo-header field is interpreted according to Section 8.1.2.3 of +% [RFC7540] instead of Section 8.3 of [RFC7540]. In particular the +% server MUST not make a new TCP connection to the host and port +% indicated by the :authority. + +reject_missing_pseudo_header_authority(Config) -> + doc("An extended CONNECT request whtout an authority component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without an :authority pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% Using Extended CONNECT To Bootstrap The WebSocket Protocol. + +reject_missing_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request whtout a protocol component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :protocol pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% The scheme of the Target URI [RFC7230] MUST be https for wss schemed +% WebSockets. HTTP/3 does not provide support for ws schemed WebSockets. +% The websocket URI is still used for proxy autoconfiguration. + +reject_connection_header(Config) -> + doc("An extended CONNECT request with a connection header " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with a connection header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"connection">>, <<"upgrade">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_upgrade_header(Config) -> + doc("An extended CONNECT request with a upgrade header " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with a upgrade header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"upgrade">>, <<"websocket">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% After successfully processing the opening handshake the peers should +% proceed with The WebSocket Protocol [RFC6455] using the HTTP/2 stream +% from the CONNECT transaction as if it were the TCP connection +% referred to in [RFC6455]. The state of the WebSocket connection at +% this point is OPEN as defined by [RFC6455], Section 4.1. +%% @todo I'm guessing we should test for things like RST_STREAM, +%% closing the connection and others? + +% Examples. + +accept_handshake_when_enabled(Config) -> + doc("Confirm the example for Websocket over HTTP/2 works. (RFC9220, RFC8441 5.1)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef), + {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + %% Masked text hello echoed back clear by the server. + Mask = 16#37fa213d, + MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>), + {ok, _} = quicer:send(StreamRef, cow_http3:data( + <<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>)), + {ok, WsData} = rfc9114_SUITE:do_receive_data(StreamRef), + << + 0, %% DATA frame. + 0:2, 7:6, %% Length (2 bytes header + "Hello"). + 1:1, 0:3, 1:4, 0:1, 5:7, "Hello" %% Websocket frame. + >> = WsData, + ok. + +%% Closing a Websocket stream. + +% The HTTP/3 stream closure is also analogous to the TCP connection +% closure of [RFC6455]. Orderly TCP-level closures are represented +% as a FIN bit on the stream (Section 4.4 of [HTTP/3]). RST exceptions +% are represented with a stream error (Section 8 of [HTTP/3]) of type +% H3_REQUEST_CANCELLED (Section 8.1 of [HTTP/3]). + +%% @todo client close frame with FIN +%% @todo server close frame with FIN +%% @todo client other frame with FIN +%% @todo server other frame with FIN +%% @todo client close connection diff --git a/test/security_SUITE.erl b/test/security_SUITE.erl index 6e217d57..944c4919 100644 --- a/test/security_SUITE.erl +++ b/test/security_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -33,13 +33,14 @@ groups() -> Tests = [nc_rand, nc_zero], H1Tests = [slowloris, slowloris_chunks], H2CTests = [ + http2_cancel_flood, http2_data_dribble, http2_empty_frame_flooding_data, http2_empty_frame_flooding_headers_continuation, http2_empty_frame_flooding_push_promise, + http2_infinite_continuations, http2_ping_flood, http2_reset_flood, - http2_cancel_flood, http2_settings_flood, http2_zero_length_header_leak ], @@ -48,10 +49,12 @@ groups() -> {https, [parallel], Tests ++ H1Tests}, {h2, [parallel], Tests}, {h2c, [parallel], Tests ++ H2CTests}, + {h3, [], Tests}, {http_compress, [parallel], Tests ++ H1Tests}, {https_compress, [parallel], Tests ++ H1Tests}, {h2_compress, [parallel], Tests}, - {h2c_compress, [parallel], Tests ++ H2CTests} + {h2c_compress, [parallel], Tests ++ H2CTests}, + {h3_compress, [], Tests} ]. init_per_suite(Config) -> @@ -65,7 +68,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -219,6 +222,38 @@ http2_empty_frame_flooding_push_promise(Config) -> {ok, <<_:24, 7:8, _:72, 1:32>>} = gen_tcp:recv(Socket, 17, 6000), ok. +http2_infinite_continuations(Config) -> + doc("Confirm that Cowboy rejects CONTINUATION frames when the " + "total size of HEADERS + CONTINUATION(s) exceeds the limit. (VU#421644)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a HEADERS frame followed by a large number + %% of continuation frames. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + HeadersBlockLen = iolist_size(HeadersBlock), + ok = gen_tcp:send(Socket, [ + %% HEADERS frame. + << + HeadersBlockLen:24, 1:8, 0:5, + 0:1, %% END_HEADERS + 0:1, + 1:1, %% END_STREAM + 0:1, + 1:31 %% Stream ID. + >>, + HeadersBlock, + %% CONTINUATION frames. + [<<1024:24, 9:8, 0:8, 0:1, 1:31, 0:1024/unit:8>> + || _ <- lists:seq(1, 100)] + ]), + %% Receive an ENHANCE_YOUR_CALM connection error. + {ok, <<_:24, 7:8, _:72, 11:32>>} = gen_tcp:recv(Socket, 17, 6000), + ok. + %% @todo http2_internal_data_buffering(Config) -> I do not know how to test this. % doc("Request many very large responses, with a larger than necessary window size, " % "but do not attempt to read from the socket. (CVE-2019-9517)"), diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl index 70762806..9620f666 100644 --- a/test/static_handler_SUITE.erl +++ b/test/static_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin +%% Copyright (c) 2016-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -20,6 +20,12 @@ -import(ct_helper, [doc/1]). -import(cowboy_test, [gun_open/1]). +%% Import useful functions from req_SUITE. +%% @todo Maybe move these functions to cowboy_test. +-import(req_SUITE, [do_get/2]). +-import(req_SUITE, [do_get/3]). +-import(req_SUITE, [do_maybe_h3_error3/1]). + %% ct. all() -> @@ -39,16 +45,22 @@ groups() -> {dir, [parallel], DirTests}, {priv_dir, [parallel], DirTests} ], + GroupTestsNoParallel = OtherTests ++ [ + {dir, [], DirTests}, + {priv_dir, [], DirTests} + ], [ {http, [parallel], GroupTests}, {https, [parallel], GroupTests}, {h2, [parallel], GroupTests}, {h2c, [parallel], GroupTests}, + {h3, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. {http_compress, [parallel], GroupTests}, {https_compress, [parallel], GroupTests}, {h2_compress, [parallel], GroupTests}, {h2c_compress, [parallel], GroupTests}, - %% No real need to test sendfile disabled against https or h2. + {h3_compress, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. + %% No real need to test sendfile disabled against https, h2 or h3. {http_no_sendfile, [parallel], GroupTests}, {h2c_no_sendfile, [parallel], GroupTests} ]. @@ -116,6 +128,17 @@ init_per_group(Name=h2c_no_sendfile, Config) -> sendfile => false }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name=h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler] + }, [{flavor, vanilla}|Config]); +init_per_group(Name=h3_compress, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler], + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, vanilla}|Config]); init_per_group(Name, Config) -> Config1 = cowboy_test:init_common_groups(Name, Config, ?MODULE), Opts = ranch:get_protocol_options(Name), @@ -129,7 +152,7 @@ end_per_group(dir, _) -> end_per_group(priv_dir, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Large file. @@ -248,25 +271,11 @@ do_mime_custom(Path) -> _ -> {<<"application">>, <<"octet-stream">>, []} end. -do_get(Path, Config) -> - do_get(Path, [], Config). - -do_get(Path, ReqHeaders, Config) -> - ConnPid = gun_open(Config), - Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|ReqHeaders]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref), - {ok, Body} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, Body}. - %% Tests. bad(Config) -> doc("Bad cowboy_static options: not a tuple."), - {500, _, _} = do_get("/bad", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad", Config)), ok. bad_dir_path(Config) -> @@ -276,7 +285,7 @@ bad_dir_path(Config) -> bad_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/dir/route", Config)), ok. bad_file_in_priv_dir_in_ez_archive(Config) -> @@ -291,27 +300,27 @@ bad_file_path(Config) -> bad_options(Config) -> doc("Bad cowboy_static extra options: not a list."), - {500, _, _} = do_get("/bad/options", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options", Config)), ok. bad_options_charset(Config) -> doc("Bad cowboy_static extra options: invalid charset option."), - {500, _, _} = do_get("/bad/options/charset", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/charset", Config)), ok. bad_options_etag(Config) -> doc("Bad cowboy_static extra options: invalid etag option."), - {500, _, _} = do_get("/bad/options/etag", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/etag", Config)), ok. bad_options_mime(Config) -> doc("Bad cowboy_static extra options: invalid mimetypes option."), - {500, _, _} = do_get("/bad/options/mime", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/mime", Config)), ok. bad_priv_dir_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/app/style.css", Config)), ok. bad_priv_dir_in_ez_archive(Config) -> @@ -331,12 +340,12 @@ bad_priv_dir_path(Config) -> bad_priv_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/priv_dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/route", Config)), ok. bad_priv_file_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_file/app", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_file/app", Config)), ok. bad_priv_file_in_ez_archive(Config) -> @@ -535,7 +544,7 @@ dir_unknown(Config) -> etag_crash(Config) -> doc("Get a file with a crashing etag function."), - {500, _, _} = do_get("/etag/crash", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/etag/crash", Config)), ok. etag_custom(Config) -> @@ -813,7 +822,7 @@ mime_all_uppercase(Config) -> mime_crash(Config) -> doc("Get a file with a crashing mimetype function."), - {500, _, _} = do_get("/mime/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/mime/crash/style.css", Config)), ok. mime_custom_cowboy(Config) -> @@ -848,7 +857,7 @@ mime_hardcode_tuple(Config) -> charset_crash(Config) -> doc("Get a file with a crashing charset function."), - {500, _, _} = do_get("/charset/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/charset/crash/style.css", Config)), ok. charset_custom_cowboy(Config) -> @@ -933,7 +942,8 @@ unicode_basic_error(Config) -> %% # and ? indicate fragment and query components %% and are therefore not part of the path. http -> "\r\s#?"; - http2 -> "#?" + http2 -> "#?"; + http3 -> "#?" end, _ = [case do_get("/char/" ++ [C], Config) of {400, _, _} -> ok; diff --git a/test/stream_handler_SUITE.erl b/test/stream_handler_SUITE.erl index e30d4201..f8e2200c 100644 --- a/test/stream_handler_SUITE.erl +++ b/test/stream_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -31,50 +31,42 @@ groups() -> %% We set this module as a logger in order to silence expected errors. init_per_group(Name = http, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_plain_opts(), Config); init_per_group(Name = https, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_plain_opts(), Config); init_per_group(Name = h2, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_plain_opts(), Config); init_per_group(Name = h2c, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config), + Config1 = cowboy_test:init_http(Name, init_plain_opts(), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(), Config); init_per_group(Name = http_compress, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_compress_opts(), Config); init_per_group(Name = https_compress, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_compress_opts(), Config); init_per_group(Name = h2_compress, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_compress_opts(), Config); init_per_group(Name = h2c_compress, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + Config1 = cowboy_test:init_http(Name, init_compress_opts(), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). + +init_plain_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [stream_handler_h] + }. + +init_compress_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [cowboy_compress_h, stream_handler_h] + }. %% Logger function silencing the expected crashes. @@ -99,15 +91,20 @@ crash_in_init(Config) -> %% Confirm terminate/3 is NOT called. We have no state to give to it. receive {Self, Pid, terminate, _, _, _} -> error(terminate) after 1000 -> ok end, %% Confirm early_error/5 is called in HTTP/1.1's case. - %% HTTP/2 does not send a response back so there is no early_error call. + %% HTTP/2 and HTTP/3 do not send a response back so there is no early_error call. case config(protocol, Config) of http -> receive {Self, Pid, early_error, _, _, _, _, _} -> ok after 1000 -> error(timeout) end; - http2 -> ok + http2 -> ok; + http3 -> ok end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok + do_await_internal_error(ConnPid, Ref, Config). + +do_await_internal_error(ConnPid, Ref, Config) -> + Protocol = config(protocol, Config), + case {Protocol, gun:await(ConnPid, Ref)} of + {http, {response, fin, 500, _}} -> ok; + {http2, {error, {stream_error, {stream_error, internal_error, _}}}} -> ok; + {http3, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> ok end. crash_in_data(Config) -> @@ -126,11 +123,7 @@ crash_in_data(Config) -> gun:data(ConnPid, Ref, fin, <<"Hello!">>), %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_info(Config) -> doc("Confirm an error is sent when a stream handler crashes in info/3."), @@ -144,14 +137,14 @@ crash_in_info(Config) -> %% Confirm init/3 is called. Pid = receive {Self, P, init, _, _, _} -> P after 1000 -> error(timeout) end, %% Send a message to make the stream handler crash. - Pid ! {{Pid, 1}, crash}, + StreamID = case config(protocol, Config) of + http3 -> 0; + _ -> 1 + end, + Pid ! {{Pid, StreamID}, crash}, %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_terminate(Config) -> doc("Confirm the state is correct when a stream handler crashes in terminate/3."), @@ -185,10 +178,12 @@ crash_in_terminate(Config) -> {ok, <<"Hello world!">>} = gun:await_body(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error(Config) -> @@ -225,10 +220,12 @@ do_crash_in_early_error(Config) -> {response, fin, 500, _} = gun:await(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error_fatal(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error_fatal(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error_fatal(Config) -> @@ -262,7 +259,8 @@ early_error_stream_error_reason(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/long_polling", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -355,11 +353,20 @@ shutdown_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Confirm we receive a DOWN message for the child process. - receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% Confirm we receive a DOWN message for the child process. + receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_on_socket_close(Config) + end. shutdown_timeout_on_stream_stop(Config) -> doc("Confirm supervised processes are killed " @@ -406,33 +413,45 @@ shutdown_timeout_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% We should NOT receive a DOWN message immediately. - receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, - %% We should receive it now. - receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% We should NOT receive a DOWN message immediately. + receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, + %% We should receive it now. + receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_timeout_on_socket_close(Config) + end. switch_protocol_after_headers(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_headers_data(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers_data">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_response(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_response">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_switch_protocol_after_response(TestCase, Config) -> @@ -502,7 +521,12 @@ terminate_on_stop(Config) -> {response, fin, 204, _} = gun:await(ConnPid, Ref), %% Confirm the stream is still alive even though we %% received the response fully, and tell it to stop. - Pid ! {{Pid, 1}, please_stop}, + StreamID = case config(protocol, Config) of + http -> 1; + http2 -> 1; + http3 -> 0 + end, + Pid ! {{Pid, StreamID}, please_stop}, receive {Self, Pid, info, _, please_stop, _} -> ok after 1000 -> error(timeout) end, %% Confirm terminate/3 is called. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, @@ -511,7 +535,8 @@ terminate_on_stop(Config) -> terminate_on_switch_protocol(Config) -> case config(protocol, Config) of http -> do_terminate_on_switch_protocol(Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_terminate_on_switch_protocol(Config) -> diff --git a/test/sys_SUITE.erl b/test/sys_SUITE.erl index cd6cfa93..2feb716f 100644 --- a/test/sys_SUITE.erl +++ b/test/sys_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/tracer_SUITE.erl b/test/tracer_SUITE.erl index 5a7d0f7f..af1f8f3f 100644 --- a/test/tracer_SUITE.erl +++ b/test/tracer_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin +%% Copyright (c) 2017-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -29,7 +29,8 @@ suite() -> %% We initialize trace patterns here. Appropriate would be in %% init_per_suite/1, but this works just as well. all() -> - cowboy_test:common_all(). + %% @todo Implement these tests for HTTP/3. + cowboy_test:common_all() -- [{group, h3}, {group, h3_compress}]. init_per_suite(Config) -> cowboy_tracer_h:set_trace_patterns(), diff --git a/test/ws_SUITE.erl b/test/ws_SUITE.erl index b0d590e0..3b743393 100644 --- a/test/ws_SUITE.erl +++ b/test/ws_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/ws_autobahn_SUITE.erl b/test/ws_autobahn_SUITE.erl index 24d76e8e..71e5c810 100644 --- a/test/ws_autobahn_SUITE.erl +++ b/test/ws_autobahn_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin +%% Copyright (c) 2011-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/ws_handler_SUITE.erl b/test/ws_handler_SUITE.erl index 362b03ae..ab1ffc84 100644 --- a/test/ws_handler_SUITE.erl +++ b/test/ws_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin +%% Copyright (c) 2018-2024, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above