diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dce9362..9c32c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,4 +85,4 @@ jobs: - uses: test-summary/action@v2 if: always() with: - paths: 'compliance-reports/h2spec.xml' + paths: 'compliance-reports/h2spec*.xml' diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0776c..3a4380e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `conn_spawn_opts => [nhttp:conn_spawn_opt()]`: spawn options for the connection processes of HTTP/1.1, HTTP/2 and HTTP/3 and for the stream workers, for example `{fullsweep_after, 0}` or `{min_heap_size, Words}`. The listener rejects `link`, `monitor` and a value that `erlang:spawn_opt/4` does not accept. Default `[]` - Response compression adds `vary: accept-encoding` to every response whose coding depends on `Accept-Encoding`. A `vary` field that the handler set is kept +- HTTP/2 in cleartext with prior knowledge (h2c, RFC 9113 §3.3): `versions => [http2]` without `tls` starts. Only `http3` needs `tls` ### Fixed diff --git a/Makefile b/Makefile index 3a43522..9efd69d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: all compile clean check test cover doc binopt certs compliance \ - h1-compliance h2-compliance h3-compliance ws-compliance \ + h1-compliance h2-compliance h2c-compliance h3-compliance ws-compliance \ h1spec-image h2spec-image h3spec-image autobahn-image help # Tools @@ -125,6 +125,15 @@ h2-compliance: certs h2spec-image -v $(CURDIR)/$(COMPLIANCE_REPORTS):/reports $(H2SPEC_IMAGE) \ -t -k -h $(COMPLIANCE_TARGET) -p $$PORT -o 5 -j /reports/h2spec.xml' +h2c-compliance: h2spec-image + @echo "=== HTTP/2 cleartext (h2c, prior knowledge) Compliance Tests with h2spec (Docker) ===" + @$(REBAR3) as test compile + @mkdir -p $(COMPLIANCE_REPORTS) + @$(COMPLIANCE_RUN) nhttp_compliance:start_h2c \ + 'docker run --rm $(DOCKER_NET_ARGS) $(DOCKER_USER) \ + -v $(CURDIR)/$(COMPLIANCE_REPORTS):/reports $(H2SPEC_IMAGE) \ + -k -h $(COMPLIANCE_TARGET) -p $$PORT -o 5 -j /reports/h2spec-h2c.xml' + h2spec-image: @command -v docker >/dev/null 2>&1 || { \ echo "Error: docker not found in PATH. Install Docker to run compliance tests."; \ @@ -176,7 +185,7 @@ autobahn-image: docker build -t $(AUTOBAHN_IMAGE) -f $(AUTOBAHN_DOCKERFILE) priv; \ } -compliance: h1-compliance h2-compliance h3-compliance ws-compliance +compliance: h1-compliance h2-compliance h2c-compliance h3-compliance ws-compliance #============================================================================== # Help @@ -205,11 +214,12 @@ help: @echo " make compliance - Run all RFC compliance tests (h1spec + h2spec + h3spec + Autobahn)" @echo " make h1-compliance - Run HTTP/1.1 tests (Docker: $(H1SPEC_IMAGE))" @echo " make h2-compliance - Run HTTP/2 RFC 9113 tests (Docker: $(H2SPEC_IMAGE))" + @echo " make h2c-compliance - Run HTTP/2 RFC 9113 tests in cleartext (Docker: $(H2SPEC_IMAGE))" @echo " make h3-compliance - Run HTTP/3 tests (Docker: $(H3SPEC_IMAGE))" @echo " make ws-compliance - Run WebSocket RFC 6455 tests (Docker: $(AUTOBAHN_IMAGE))" @echo "" @echo " Each target exits with the status of its runner. Reports land in" - @echo " $(COMPLIANCE_REPORTS)/: h2spec.xml (JUnit) and autobahn/index.html." + @echo " $(COMPLIANCE_REPORTS)/: h2spec.xml and h2spec-h2c.xml (JUnit), and autobahn/index.html." @echo " make -k compliance runs every suite before it reports a failure." @echo "" @echo " Requirement: Docker daemon running. On macOS/Windows, Docker Desktop." diff --git a/README.md b/README.md index 9fc2e47..ece77d4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,18 @@ responses, WebSocket, SSE, CORS, and chunked uploads. }). ``` +### HTTP/2 in cleartext (h2c) + +Without `tls`, `versions => [http2]` serves HTTP/2 with prior knowledge (RFC 9113 §3.3), for example to `curl --http2-prior-knowledge`. Use it only behind a TLS terminator or inside a trusted network. + +```erlang +{ok, Pid} = nhttp:start_link(my_server, #{ + port => 8080, + versions => [http2], + handler => my_handler +}). +``` + ### HTTP/3 over QUIC HTTP/3 runs over QUIC on a UDP socket. HTTP/1.1 and HTTP/2 run over TCP on their own socket. List all three versions and a single listener opens both sockets for you, and sets Alt-Svc on the TCP side so clients know where to find HTTP/3. @@ -67,7 +79,7 @@ HTTP/3 runs over QUIC on a UDP socket. HTTP/1.1 and HTTP/2 run over TCP on their ## Features -- **HTTP/1.1** ([RFC 9112](https://www.rfc-editor.org/rfc/rfc9112)) and **HTTP/2** ([RFC 9113](https://www.rfc-editor.org/rfc/rfc9113)) with automatic protocol selection via TLS ALPN +- **HTTP/1.1** ([RFC 9112](https://www.rfc-editor.org/rfc/rfc9112)) and **HTTP/2** ([RFC 9113](https://www.rfc-editor.org/rfc/rfc9113)) with automatic protocol selection via TLS ALPN, and HTTP/2 in cleartext with prior knowledge - **HTTP/3** over QUIC ([RFC 9114](https://www.rfc-editor.org/rfc/rfc9114)), in-process `nquic` library mode - **WebSocket** over HTTP/1.1 ([RFC 6455](https://www.rfc-editor.org/rfc/rfc6455)), HTTP/2 ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441)), and HTTP/3 ([RFC 9220](https://www.rfc-editor.org/rfc/rfc9220)) with one callback set - **Server-Sent Events** helpers ([W3C SSE](https://html.spec.whatwg.org/multipage/server-sent-events.html)) diff --git a/priv/compliance/run.sh b/priv/compliance/run.sh index 99617ba..cef73db 100755 --- a/priv/compliance/run.sh +++ b/priv/compliance/run.sh @@ -2,13 +2,16 @@ # # Runs one compliance suite against a server started for it. # -# Starts :start() in a rebar3 test shell, waits for the +# Starts () in a rebar3 test shell, waits for the # PORT: line it prints, runs with PORT in its environment, # stops the server and its process group, and exits with the status of # . That status is the receipt: a suite that fails here fails # the make target and the CI job. # -# Usage: run.sh +# Usage: run.sh +# +# is : or a bare , which means +# :start. # # is one string, run with `bash -c`, so it reads $PORT at run # time. Example: @@ -16,8 +19,12 @@ set -uo pipefail -module=${1:?usage: run.sh } -command=${2:?usage: run.sh } +entry=${1:?usage: run.sh } +command=${2:?usage: run.sh } +case $entry in + *:*) ;; + *) entry=${entry}:start ;; +esac rebar3=${REBAR3:-rebar3} start_timeout_ticks=300 @@ -29,12 +36,12 @@ cleanup() { pkill -TERM -P "$server_pid" 2>/dev/null kill -TERM "$server_pid" 2>/dev/null fi - pkill -TERM -f "${module}:start\(\)" 2>/dev/null + pkill -TERM -f "${entry}\(\)" 2>/dev/null rm -f "$log" } trap cleanup EXIT -( tail -f /dev/null | "$rebar3" as test shell --eval "${module}:start()." ) > "$log" 2>&1 & +( tail -f /dev/null | "$rebar3" as test shell --eval "${entry}()." ) > "$log" 2>&1 & server_pid=$! port="" @@ -50,10 +57,10 @@ for _ in $(seq 1 "$start_timeout_ticks"); do done if [ -z "$port" ]; then - echo "compliance: ${module} did not report a port. Server output:" >&2 + echo "compliance: ${entry} did not report a port. Server output:" >&2 cat "$log" >&2 exit 1 fi -echo "compliance: ${module} listening on port ${port}" +echo "compliance: ${entry} listening on port ${port}" PORT=$port bash -c "$command" diff --git a/src/nhttp.erl b/src/nhttp.erl index 219fca4..dde3b1c 100644 --- a/src/nhttp.erl +++ b/src/nhttp.erl @@ -80,6 +80,26 @@ end, `sni_hosts` and `sni_fun` may be combined and are TCP/TLS only. HTTP/3 listeners ignore them. +## HTTP/2 in cleartext (h2c) + +`versions => [http2]` without `tls` serves HTTP/2 over plain TCP with +prior knowledge (RFC 9113 §3.3). The client sends the HTTP/2 connection +preface first, as `curl --http2-prior-knowledge` and gRPC clients do. +`Upgrade: h2c` is not supported. Requests carry the `:scheme` that the +client sends. + +```erlang +{ok, Pid} = nhttp:start_link(my_srv, #{ + port => 8080, + handler => my_handler, + versions => [http2] +}). +``` + +Use cleartext HTTP/2 only behind a TLS terminator or inside a trusted +network. A cleartext listener serves one protocol: the first of +`http1_1` and `http2` in `versions`. `http3` always needs `tls`. + ## Mixed h1/h2/h3 listeners `versions => [http1_1, http2, http3]` serves all three from one call. h1 diff --git a/src/nhttp_listener.erl b/src/nhttp_listener.erl index c5c5aa1..de5d99d 100644 --- a/src/nhttp_listener.erl +++ b/src/nhttp_listener.erl @@ -726,8 +726,7 @@ validate_versions(Opts) -> -spec validate_versions_tls([nhttp:version()], nhttp:opts()) -> ok | nhttp_error:t(). validate_versions_tls(Versions, Opts) -> - NeedsTls = lists:member(http2, Versions) orelse lists:member(http3, Versions), - case {NeedsTls, maps:find(tls, Opts)} of + case {lists:member(http3, Versions), maps:find(tls, Opts)} of {false, error} -> ok; {true, error} -> diff --git a/test/nhttp_compliance.erl b/test/nhttp_compliance.erl index 770704d..b5f705e 100644 --- a/test/nhttp_compliance.erl +++ b/test/nhttp_compliance.erl @@ -15,7 +15,8 @@ -export([ start/0, - start/1 + start/1, + start_h2c/0 ]). -export([ @@ -30,6 +31,11 @@ start() -> start(#{}). +-doc "Start the server in cleartext, HTTP/2 with prior knowledge (h2c).". +-spec start_h2c() -> no_return(). +start_h2c() -> + start(#{tls => false}). + -spec start(map()) -> no_return(). start(Opts) -> {ok, _} = application:ensure_all_started(crypto), @@ -39,24 +45,9 @@ start(Opts) -> {ok, _} = application:ensure_all_started(nhttp_lib), {ok, _} = application:ensure_all_started(nhttp), - CertDir = maps:get(cert_dir, Opts, ?DEFAULT_CERT_DIR), - CertFile = maps:get(certfile, Opts, filename:join(CertDir, "server.pem")), - KeyFile = maps:get(keyfile, Opts, filename:join(CertDir, "server.key")), - - case filelib:is_file(CertFile) of - false -> - io:format(standard_error, "Error: Certificate not found: ~s~n", [CertFile]), - halt(1); - true -> - ok - end, - - {ok, Pid} = nhttp:start_link(#{ - port => 0, - tls => #{certfile => CertFile, keyfile => KeyFile}, - versions => [http2], - handler => ?MODULE - }), + {ok, Pid} = nhttp:start_link( + maps:merge(#{port => 0, versions => [http2], handler => ?MODULE}, tls_opts(Opts)) + ), {ok, Port} = nhttp:get_port(Pid), @@ -68,6 +59,21 @@ start(Opts) -> halt(0) end. +-spec tls_opts(map()) -> #{tls => nhttp:tls()}. +tls_opts(#{tls := false}) -> + #{}; +tls_opts(Opts) -> + CertDir = maps:get(cert_dir, Opts, ?DEFAULT_CERT_DIR), + CertFile = maps:get(certfile, Opts, filename:join(CertDir, "server.pem")), + KeyFile = maps:get(keyfile, Opts, filename:join(CertDir, "server.key")), + case filelib:is_file(CertFile) of + false -> + io:format(standard_error, "Error: Certificate not found: ~s~n", [CertFile]), + halt(1); + true -> + #{tls => #{certfile => CertFile, keyfile => KeyFile}} + end. + -spec init(term()) -> {ok, #{}}. init(_Args) -> {ok, #{}}. diff --git a/test/nhttp_conn_SUITE.erl b/test/nhttp_conn_SUITE.erl index 4b19a71..9a920d8 100644 --- a/test/nhttp_conn_SUITE.erl +++ b/test/nhttp_conn_SUITE.erl @@ -107,7 +107,7 @@ alpn_h2_not_allowed/1, alpn_no_alpn_fallback/1, alpn_http1_0/1, - alpn_h2_first_fallback/1, + cleartext_h2_first_serves_h2c/1, h1_http1_0_no_keepalive/1, h1_http1_0_explicit_keepalive/1, h1_compression_empty_body/1, @@ -433,7 +433,7 @@ groups() -> alpn_h2_not_allowed, alpn_no_alpn_fallback, alpn_http1_0, - alpn_h2_first_fallback + cleartext_h2_first_serves_h2c ]}, {h1_http1_0, [sequence], [ h1_http1_0_no_keepalive, @@ -3322,17 +3322,19 @@ conn_parent_exit(_Config) -> %%% ADDITIONAL PROTOCOL DETECTION TESTS %%%----------------------------------------------------------------------------- -alpn_h2_first_fallback(_Config) -> - case - nhttp:start_link(#{ - port => 0, - handler => ?MODULE, - versions => [http2, http1_1] - }) - of - {error, _} -> ok; - {ok, Pid} -> ct:fail({unexpected_start, Pid}) - end. +cleartext_h2_first_serves_h2c(_Config) -> + {ok, Pid} = nhttp:start_link(#{ + port => 0, + handler => ?MODULE, + versions => [http2, http1_1] + }), + {ok, Port} = nhttp:get_port(Pid), + {ok, Sock} = nhttp_test_helpers:h2c_connect(Port), + ok = nhttp_test_helpers:h2_send_request(Sock, 1, <<"/">>), + Frames = nhttp_test_helpers:h2_recv_stream(Sock, 1, 5000), + ?assertEqual(<<"200">>, nhttp_test_helpers:h2_response_status(Frames)), + ok = gen_tcp:close(Sock), + ok = nhttp:stop(Pid). %%%----------------------------------------------------------------------------- %%% HIBERNATION TESTS diff --git a/test/nhttp_h2c_SUITE.erl b/test/nhttp_h2c_SUITE.erl new file mode 100644 index 0000000..c4a2d0f --- /dev/null +++ b/test/nhttp_h2c_SUITE.erl @@ -0,0 +1,169 @@ +-module(nhttp_h2c_SUITE). + +-moduledoc """ +HTTP/2 over cleartext TCP with prior knowledge (RFC 9113 §3.3): a listener +with `versions => [http2]` and no `tls`. +""". + +%%%----------------------------------------------------------------------------- +%%% INCLUDES +%%%----------------------------------------------------------------------------- +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +%%%----------------------------------------------------------------------------- +%%% COMMON TEST CALLBACKS +%%%----------------------------------------------------------------------------- +-export([ + all/0, + end_per_testcase/2, + init_per_testcase/2 +]). + +%%%----------------------------------------------------------------------------- +%%% TEST CASES +%%%----------------------------------------------------------------------------- +-export([ + concurrent_streams/1, + drain_sends_goaway/1, + get_request/1, + h1_request_gets_goaway/1, + post_with_body/1, + request_scheme_is_http/1 +]). + +%%%----------------------------------------------------------------------------- +%%% HANDLER +%%%----------------------------------------------------------------------------- +-behaviour(nhttp_handler). + +-export([ + handle_request/2, + handle_request_body/3, + init/1 +]). + +-define(RECV_TIMEOUT, 5000). +-define(CONCURRENT_STREAMS, 100). +-define(PROTOCOL_ERROR, 1). + +init(TestPid) -> + {ok, #{test => TestPid}}. + +handle_request(#{path := <<"/reply">>}, State) -> + {reply, nhttp_resp:ok(<<"OK">>), State}; +handle_request(#{path := <<"/scheme">>} = Req, #{test := TestPid} = State) -> + TestPid ! {scheme, nhttp_req:scheme(Req)}, + {reply, nhttp_resp:ok(<<"OK">>), State}; +handle_request(#{path := <<"/echo">>}, State) -> + {accept_body, [], State}. + +handle_request_body({data, Data}, Acc, State) -> + {accept_body, [Data | Acc], State}; +handle_request_body({fin, _Trailers}, Acc, State) -> + {reply, nhttp_resp:ok(iolist_to_binary(lists:reverse(Acc))), State}. + +%%%----------------------------------------------------------------------------- +%%% SUITE CALLBACKS +%%%----------------------------------------------------------------------------- +all() -> + [ + get_request, + post_with_body, + concurrent_streams, + request_scheme_is_http, + drain_sends_goaway, + h1_request_gets_goaway + ]. + +init_per_testcase(_TestCase, Config) -> + {Pid, Port} = nhttp_test_helpers:h2c_start_server(?MODULE, #{handler_args => self()}), + [{server, Pid}, {port, Port} | Config]. + +end_per_testcase(_TestCase, Config) -> + ok = nhttp:stop(?config(server, Config)), + ok. + +%%%----------------------------------------------------------------------------- +%%% TEST CASES +%%%----------------------------------------------------------------------------- +get_request(Config) -> + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + ok = nhttp_test_helpers:h2_send_request(Sock, 1, <<"/reply">>), + Frames = nhttp_test_helpers:h2_recv_stream(Sock, 1, ?RECV_TIMEOUT), + ?assertEqual(<<"200">>, nhttp_test_helpers:h2_response_status(Frames)), + ?assertEqual(<<"OK">>, nhttp_test_helpers:h2_response_body(Frames, 1)), + ok = gen_tcp:close(Sock). + +post_with_body(Config) -> + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + ok = nhttp_test_helpers:h2_send_post(Sock, 1, <<"/echo">>, <<"hello">>), + Frames = nhttp_test_helpers:h2_recv_stream(Sock, 1, ?RECV_TIMEOUT), + ?assertEqual(<<"hello">>, nhttp_test_helpers:h2_response_body(Frames, 1)), + ok = gen_tcp:close(Sock). + +concurrent_streams(Config) -> + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + StreamIds = lists:seq(1, 2 * ?CONCURRENT_STREAMS - 1, 2), + lists:foreach( + fun(Id) -> ok = nhttp_test_helpers:h2_open_stream(Sock, Id, <<"/echo">>) end, + StreamIds + ), + lists:foreach( + fun(Id) -> + ok = nhttp_test_helpers:h2_send_data(Sock, Id, integer_to_binary(Id), true) + end, + StreamIds + ), + Frames = recv_until_done(Sock, StreamIds, <<>>, []), + lists:foreach( + fun(Id) -> + ?assertEqual(integer_to_binary(Id), nhttp_test_helpers:h2_response_body(Frames, Id)) + end, + StreamIds + ), + ok = gen_tcp:close(Sock). + +request_scheme_is_http(Config) -> + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + ok = nhttp_test_helpers:h2_send_request(Sock, 1, <<"/scheme">>), + Frames = nhttp_test_helpers:h2_recv_stream(Sock, 1, ?RECV_TIMEOUT), + ?assertEqual(<<"OK">>, nhttp_test_helpers:h2_response_body(Frames, 1)), + receive + {scheme, Scheme} -> ?assertEqual(http, Scheme) + after ?RECV_TIMEOUT -> ct:fail(handler_not_called) + end, + ok = gen_tcp:close(Sock). + +drain_sends_goaway(Config) -> + Server = ?config(server, Config), + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + ok = nhttp_test_helpers:h2_send_request(Sock, 1, <<"/reply">>), + Frames1 = nhttp_test_helpers:h2_recv_stream(Sock, 1, ?RECV_TIMEOUT), + ?assertEqual(<<"OK">>, nhttp_test_helpers:h2_response_body(Frames1, 1)), + ok = nhttp:drain(Server, ?RECV_TIMEOUT), + Frames2 = nhttp_test_helpers:h2_recv(Sock, ?RECV_TIMEOUT), + ?assertMatch([{goaway, 0, <<0:1, 1:31, 0:32>>} | _], [F || {goaway, _, _} = F <- Frames2]), + ok = gen_tcp:close(Sock). + +h1_request_gets_goaway(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + ok = gen_tcp:send(Sock, <<"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n">>), + {Frames, _Rest} = nhttp_test_helpers:decode_h2_frames(nhttp_test_helpers:drain_tcp(Sock)), + ?assertMatch( + [{goaway, 0, <<_:32, ?PROTOCOL_ERROR:32>>}], [F || {goaway, _, _} = F <- Frames] + ), + ok = gen_tcp:close(Sock). + +%%%----------------------------------------------------------------------------- +%%% HELPERS +%%%----------------------------------------------------------------------------- +recv_until_done(Sock, StreamIds, Buf, Acc) -> + case lists:all(fun(Id) -> nhttp_test_helpers:h2_stream_done(Acc, Id) end, StreamIds) of + true -> + Acc; + false -> + {ok, Data} = gen_tcp:recv(Sock, 0, ?RECV_TIMEOUT), + {Frames, Rest} = nhttp_test_helpers:decode_h2_frames(<>), + recv_until_done(Sock, StreamIds, Rest, Acc ++ Frames) + end. diff --git a/test/nhttp_listener_validation_SUITE.erl b/test/nhttp_listener_validation_SUITE.erl index f874a43..3b5582f 100644 --- a/test/nhttp_listener_validation_SUITE.erl +++ b/test/nhttp_listener_validation_SUITE.erl @@ -33,6 +33,8 @@ listener_h2_credit_batch_invalid/1, listener_h2_response_delay_invalid/1, listener_h2_window_policy_invalid/1, + listener_h2c_without_tls_starts/1, + listener_http3_requires_tls/1, listener_invalid_tls/1, listener_invalid_versions/1, listener_listen_failed/1, @@ -53,6 +55,8 @@ all() -> listener_h2_credit_batch_invalid, listener_h2_response_delay_invalid, listener_h2_window_policy_invalid, + listener_h2c_without_tls_starts, + listener_http3_requires_tls, listener_invalid_tls, listener_invalid_versions, listener_listen_failed, @@ -263,6 +267,21 @@ listener_h2_window_policy_invalid(_Config) -> ), ok. +listener_h2c_without_tls_starts(_Config) -> + {ok, Pid} = nhttp:start_link(#{port => 0, handler => ?MODULE, versions => [http2]}), + ok = nhttp:stop(Pid). + +listener_http3_requires_tls(_Config) -> + lists:foreach( + fun(Versions) -> + assert_error_contains( + "key => tls", + nhttp:start_link(#{port => 0, handler => ?MODULE, versions => Versions}) + ) + end, + [[http3], [http2, http3], [http1_1, http3]] + ). + listener_invalid_versions(_Config) -> assert_error_contains( "invalid_versions", diff --git a/test/nhttp_test_helpers.erl b/test/nhttp_test_helpers.erl index 122c6c3..8bdf061 100644 --- a/test/nhttp_test_helpers.erl +++ b/test/nhttp_test_helpers.erl @@ -44,6 +44,8 @@ h2_server_settings/1, h2_start_server/2, h2_stream_done/2, + h2c_connect/1, + h2c_start_server/2, stream_data_size/2, tcp_connect/1 ]). @@ -53,6 +55,9 @@ -define(POLL_INTERVAL, 25). -define(SETTINGS_RECV_MS, 1000). -define(STREAM_TAIL_MS, 200). +-define(H2_PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). + +-type h2_sock() :: ssl:sslsocket() | gen_tcp:socket(). -type h2_frame() :: {data, non_neg_integer(), binary(), fin | nofin} @@ -63,7 +68,7 @@ | {window_update, non_neg_integer(), non_neg_integer()} | {other, non_neg_integer(), non_neg_integer(), binary()}. --export_type([h2_frame/0]). +-export_type([h2_frame/0, h2_sock/0]). %%%----------------------------------------------------------------------------- %%% API @@ -152,6 +157,21 @@ h2_start_server(Handler, Extra) -> ), {Pid, Port}. +-doc "Start an HTTP/2-only cleartext listener (h2c with prior knowledge) on a free port.". +-spec h2c_start_server(module(), nhttp:opts()) -> {pid(), inet:port_number()}. +h2c_start_server(Handler, Extra) -> + {ok, Pid, Port} = start( + maps:merge( + #{ + handler => Handler, + versions => [http2], + timeouts => #{idle => ?H2_IDLE_TIMEOUT} + }, + Extra + ) + ), + {Pid, Port}. + -spec conn_pids(pid()) -> [pid()]. conn_pids(ListenerPid) -> lists:append([conns_in(TSup) || TSup <- transport_sups(ListenerPid)]). @@ -205,6 +225,15 @@ h2_connect(Port) -> ok = ssl:send(Sock, <<0, 0, 0, 4, 1, 0, 0, 0, 0>>), {ok, Sock}. +-doc "Open a cleartext HTTP/2 connection with prior knowledge and finish the SETTINGS exchange.". +-spec h2c_connect(inet:port_number()) -> {ok, gen_tcp:socket()}. +h2c_connect(Port) -> + {ok, Sock} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(Sock, [?H2_PREFACE, <<0, 0, 0, 4, 0, 0, 0, 0, 0>>]), + _ = gen_tcp:recv(Sock, 0, 2000), + ok = gen_tcp:send(Sock, <<0, 0, 0, 4, 1, 0, 0, 0, 0>>), + {ok, Sock}. + -doc """ Open a connection and return the parameters of the SETTINGS frame the server sends in its preface, as `{Identifier, Value}` pairs. @@ -216,36 +245,36 @@ h2_server_settings(Port) -> ok = ssl:close(Sock), [{Id, Value} || {settings, 0, Payload} <- Frames, <> <= Payload]. --spec h2_send_request(ssl:sslsocket(), non_neg_integer(), binary()) -> ok. +-spec h2_send_request(h2_sock(), non_neg_integer(), binary()) -> ok. h2_send_request(Sock, StreamId, Path) -> - Header = h2_header_block(Path), + Header = h2_header_block(Sock, Path), Frame = <<(byte_size(Header)):24, 1, 5, 0:1, StreamId:31, Header/binary>>, - ssl:send(Sock, Frame). + sock_send(Sock, Frame). --spec h2_open_stream(ssl:sslsocket(), non_neg_integer(), binary()) -> ok. +-spec h2_open_stream(h2_sock(), non_neg_integer(), binary()) -> ok. h2_open_stream(Sock, StreamId, Path) -> - Header = h2_header_block(Path), + Header = h2_header_block(Sock, Path), Frame = <<(byte_size(Header)):24, 1, 4, 0:1, StreamId:31, Header/binary>>, - ssl:send(Sock, Frame). + sock_send(Sock, Frame). --spec h2_send_raw(ssl:sslsocket(), iodata()) -> ok. +-spec h2_send_raw(h2_sock(), iodata()) -> ok. h2_send_raw(Sock, Bytes) -> - ssl:send(Sock, Bytes). + sock_send(Sock, Bytes). --spec h2_send_rst_stream(ssl:sslsocket(), non_neg_integer(), non_neg_integer()) -> ok. +-spec h2_send_rst_stream(h2_sock(), non_neg_integer(), non_neg_integer()) -> ok. h2_send_rst_stream(Sock, StreamId, ErrorCode) -> - ssl:send(Sock, <<4:24, 3, 0, 0:1, StreamId:31, ErrorCode:32>>). + sock_send(Sock, <<4:24, 3, 0, 0:1, StreamId:31, ErrorCode:32>>). --spec h2_send_settings(ssl:sslsocket(), [{non_neg_integer(), non_neg_integer()}]) -> ok. +-spec h2_send_settings(h2_sock(), [{non_neg_integer(), non_neg_integer()}]) -> ok. h2_send_settings(Sock, Settings) -> Payload = <<<> || {Id, Value} <- Settings>>, - ssl:send(Sock, <<(byte_size(Payload)):24, 4, 0, 0:1, 0:31, Payload/binary>>). + sock_send(Sock, <<(byte_size(Payload)):24, 4, 0, 0:1, 0:31, Payload/binary>>). --spec h2_send_window_update(ssl:sslsocket(), non_neg_integer(), non_neg_integer()) -> ok. +-spec h2_send_window_update(h2_sock(), non_neg_integer(), non_neg_integer()) -> ok. h2_send_window_update(Sock, StreamId, Increment) -> - ssl:send(Sock, <<4:24, 8, 0, 0:1, StreamId:31, 0:1, Increment:31>>). + sock_send(Sock, <<4:24, 8, 0, 0:1, StreamId:31, 0:1, Increment:31>>). --spec h2_recv(ssl:sslsocket(), timeout()) -> [h2_frame()]. +-spec h2_recv(h2_sock(), timeout()) -> [h2_frame()]. h2_recv(Sock, Timeout) -> h2_recv(Sock, Timeout, <<>>, []). @@ -253,7 +282,7 @@ h2_recv(Sock, Timeout) -> Receive frames until the stream ends (END_STREAM or RST_STREAM), then drain the socket for a short tail so frames sent right after are seen. """. --spec h2_recv_stream(ssl:sslsocket(), non_neg_integer(), timeout()) -> [h2_frame()]. +-spec h2_recv_stream(h2_sock(), non_neg_integer(), timeout()) -> [h2_frame()]. h2_recv_stream(Sock, StreamId, Timeout) -> Frames = recv_stream_until_done(Sock, StreamId, Timeout, <<>>, []), Frames ++ recv_stream_tail(Sock, <<>>, []). @@ -289,7 +318,7 @@ h2_stream_done(Frames, StreamId) -> Frames ). --spec h2_send_data(ssl:sslsocket(), non_neg_integer(), binary(), boolean()) -> +-spec h2_send_data(h2_sock(), non_neg_integer(), binary(), boolean()) -> ok | {error, term()}. h2_send_data(Sock, StreamId, Data, EndStream) -> Flags = @@ -298,17 +327,17 @@ h2_send_data(Sock, StreamId, Data, EndStream) -> false -> 16#00 end, Frame = <<(byte_size(Data)):24, 0, Flags, 0:1, StreamId:31, Data/binary>>, - ssl:send(Sock, Frame). + sock_send(Sock, Frame). -doc "Send a POST HEADERS frame with `content-length` set to `Length`.". -spec h2_send_headers( - ssl:sslsocket(), non_neg_integer(), binary(), non_neg_integer(), boolean() + h2_sock(), non_neg_integer(), binary(), non_neg_integer(), boolean() ) -> ok | {error, term()}. h2_send_headers(Sock, StreamId, Path, Length, EndStream) -> {ok, Enc} = nhttp_hpack:new(), Headers = [ {<<":method">>, <<"POST">>}, - {<<":scheme">>, <<"https">>}, + {<<":scheme">>, scheme(Sock)}, {<<":authority">>, <<"localhost">>}, {<<":path">>, Path}, {<<"content-length">>, integer_to_binary(Length)} @@ -321,9 +350,9 @@ h2_send_headers(Sock, StreamId, Path, Length, EndStream) -> false -> 16#04 end, Frame = <<(byte_size(Block)):24, 1, Flags, 0:1, StreamId:31, Block/binary>>, - ssl:send(Sock, Frame). + sock_send(Sock, Frame). --spec h2_send_post(ssl:sslsocket(), non_neg_integer(), binary(), binary()) -> +-spec h2_send_post(h2_sock(), non_neg_integer(), binary(), binary()) -> ok | {error, term()}. h2_send_post(Sock, StreamId, Path, Body) -> ok = h2_send_headers(Sock, StreamId, Path, byte_size(Body), false), @@ -342,18 +371,39 @@ stream_data_size(Frames, StreamId) -> %%% INTERNAL %%%----------------------------------------------------------------------------- --spec h2_header_block(binary()) -> binary(). -h2_header_block(Path) -> +-doc "GET with the `:scheme` of the transport, from the HPACK static table.". +-spec h2_header_block(h2_sock(), binary()) -> binary(). +h2_header_block(Sock, Path) -> PathLen = byte_size(Path), - <<16#82, 16#87, 16#44, PathLen, Path/binary>>. + <<16#82, (scheme_index(Sock)), 16#44, PathLen, Path/binary>>. + +-spec scheme(h2_sock()) -> binary(). +scheme(Sock) when is_port(Sock) -> <<"http">>; +scheme(_Sock) -> <<"https">>. + +-spec scheme_index(h2_sock()) -> byte(). +scheme_index(Sock) when is_port(Sock) -> 16#86; +scheme_index(_Sock) -> 16#87. + +-spec sock_recv(h2_sock(), non_neg_integer(), timeout()) -> {ok, binary()} | {error, term()}. +sock_recv(Sock, Length, Timeout) when is_port(Sock) -> + gen_tcp:recv(Sock, Length, Timeout); +sock_recv(Sock, Length, Timeout) -> + ssl:recv(Sock, Length, Timeout). + +-spec sock_send(h2_sock(), iodata()) -> ok | {error, term()}. +sock_send(Sock, Data) when is_port(Sock) -> + gen_tcp:send(Sock, Data); +sock_send(Sock, Data) -> + ssl:send(Sock, Data). --spec h2_recv(ssl:sslsocket(), timeout(), binary(), [h2_frame()]) -> [h2_frame()]. +-spec h2_recv(h2_sock(), timeout(), binary(), [h2_frame()]) -> [h2_frame()]. h2_recv(_Sock, Timeout, Buf, Acc) when Timeout =< 0 -> {Frames, _Rest} = decode_h2_frames(Buf), Acc ++ Frames; h2_recv(Sock, Timeout, Buf, Acc) -> T0 = erlang:monotonic_time(millisecond), - case ssl:recv(Sock, 0, Timeout) of + case sock_recv(Sock, 0, Timeout) of {ok, Data} -> {Frames, Rest} = decode_h2_frames(<>), Elapsed = erlang:monotonic_time(millisecond) - T0, @@ -401,7 +451,7 @@ h2_connect_preface(Port) -> ], 5000 ), - ok = ssl:send(Sock, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>), + ok = ssl:send(Sock, ?H2_PREFACE), ok = ssl:send(Sock, <<0, 0, 0, 4, 0, 0, 0, 0, 0>>), {ok, Sock}. @@ -410,14 +460,14 @@ h2_fin(Flags) when Flags band 1 =:= 1 -> fin; h2_fin(_Flags) -> nofin. -spec recv_stream_until_done( - ssl:sslsocket(), non_neg_integer(), timeout(), binary(), [h2_frame()] + h2_sock(), non_neg_integer(), timeout(), binary(), [h2_frame()] ) -> [h2_frame()]. recv_stream_until_done(Sock, StreamId, Timeout, Buf, Acc) -> case h2_stream_done(Acc, StreamId) of true -> Acc; false -> - case ssl:recv(Sock, 0, Timeout) of + case sock_recv(Sock, 0, Timeout) of {ok, Data} -> {Frames, Rest} = decode_h2_frames(<>), recv_stream_until_done(Sock, StreamId, Timeout, Rest, Acc ++ Frames); @@ -426,9 +476,9 @@ recv_stream_until_done(Sock, StreamId, Timeout, Buf, Acc) -> end end. --spec recv_stream_tail(ssl:sslsocket(), binary(), [h2_frame()]) -> [h2_frame()]. +-spec recv_stream_tail(h2_sock(), binary(), [h2_frame()]) -> [h2_frame()]. recv_stream_tail(Sock, Buf, Acc) -> - case ssl:recv(Sock, 0, ?STREAM_TAIL_MS) of + case sock_recv(Sock, 0, ?STREAM_TAIL_MS) of {ok, Data} -> {Frames, Rest} = decode_h2_frames(<>), recv_stream_tail(Sock, Rest, Acc ++ Frames);