diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4380e..2355f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,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` +- HTTP/1.1 and h2c on one cleartext port: `versions => [http1_1, http2]` without `tls` picks the protocol of each connection from its first bytes, HTTP/2 when they are the connection preface. A connection that sends no deciding bytes before the `request` timeout is closed ### Fixed diff --git a/Makefile b/Makefile index 9efd69d..b64800b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .PHONY: all compile clean check test cover doc binopt certs compliance \ - h1-compliance h2-compliance h2c-compliance h3-compliance ws-compliance \ + h1-compliance h2-compliance h2c-compliance mixed-compliance \ + h3-compliance ws-compliance \ h1spec-image h2spec-image h3spec-image autobahn-image help # Tools @@ -13,6 +14,10 @@ H1SPEC_VERSION := 0.2.0-f0a5650 H1SPEC_IMAGE := nhttp/h1spec:$(H1SPEC_VERSION) H1SPEC_DOCKERFILE := priv/Dockerfile.h1spec H2SPEC_IMAGE := summerwind/h2spec:2.6.0 +# Every h2spec case except http2/3.5/2. On a port that also serves HTTP/1.1 +# its invalid preface gets an HTTP/1.1 400, which h2spec reads as a frame. +# https://github.com/summerwind/h2spec/issues/112 +H2SPEC_MIXED_SPECS := generic hpack http2/3.5/1 http2/4 http2/5 http2/6 http2/7 http2/8 H3SPEC_VERSION := 0.1.12 H3SPEC_IMAGE := nhttp/h3spec:$(H3SPEC_VERSION) H3SPEC_DOCKERFILE := priv/Dockerfile.h3spec @@ -134,6 +139,20 @@ h2c-compliance: h2spec-image -v $(CURDIR)/$(COMPLIANCE_REPORTS):/reports $(H2SPEC_IMAGE) \ -k -h $(COMPLIANCE_TARGET) -p $$PORT -o 5 -j /reports/h2spec-h2c.xml' +mixed-compliance: h1spec-image h2spec-image + @echo "=== HTTP/1.1 and h2c on one cleartext port: h2spec and h1spec (Docker) ===" + @$(REBAR3) as test compile + @mkdir -p $(COMPLIANCE_REPORTS) + @$(COMPLIANCE_RUN) nhttp_compliance:start_mixed \ + '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-mixed.xml \ + $(H2SPEC_MIXED_SPECS); \ + h2=$$?; \ + docker run --rm $(DOCKER_NET_ARGS) $(H1SPEC_IMAGE) $(COMPLIANCE_TARGET) $$PORT; \ + h1=$$?; \ + test $$h2 -eq 0 -a $$h1 -eq 0' + h2spec-image: @command -v docker >/dev/null 2>&1 || { \ echo "Error: docker not found in PATH. Install Docker to run compliance tests."; \ @@ -185,7 +204,8 @@ autobahn-image: docker build -t $(AUTOBAHN_IMAGE) -f $(AUTOBAHN_DOCKERFILE) priv; \ } -compliance: h1-compliance h2-compliance h2c-compliance h3-compliance ws-compliance +compliance: h1-compliance h2-compliance h2c-compliance mixed-compliance h3-compliance \ + ws-compliance #============================================================================== # Help @@ -215,11 +235,12 @@ help: @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 mixed-compliance - Run h2spec and h1spec on one cleartext HTTP/1.1 + h2c port" @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 and h2spec-h2c.xml (JUnit), and autobahn/index.html." + @echo " $(COMPLIANCE_REPORTS)/: h2spec.xml, h2spec-h2c.xml and h2spec-mixed.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 ece77d4..a25db34 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ 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. +Without `tls`, `versions => [http2]` serves HTTP/2 with prior knowledge (RFC 9113 §3.3), for example to `curl --http2-prior-knowledge`. `versions => [http1_1, http2]` without `tls` serves both on one port and picks the protocol of each connection from its first bytes. Use cleartext HTTP/2 only behind a TLS terminator or inside a trusted network. ```erlang {ok, Pid} = nhttp:start_link(my_server, #{ diff --git a/src/nhttp.erl b/src/nhttp.erl index dde3b1c..285f864 100644 --- a/src/nhttp.erl +++ b/src/nhttp.erl @@ -96,9 +96,15 @@ client sends. }). ``` +`versions => [http1_1, http2]` without `tls` serves both on one port. Each +connection gets HTTP/2 when its first 14 bytes are `PRI * HTTP/2.0`, the +start of the connection preface (RFC 9113 §3.4), and HTTP/1.1 from the +first byte that differs. A connection that sends no deciding bytes +before the `request` timeout, or the `idle` timeout when it is shorter, +is closed without a response. + 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`. +network. `http3` always needs `tls`. ## Mixed h1/h2/h3 listeners @@ -322,7 +328,8 @@ Connection timeouts (milliseconds, or `infinity`). Default 60000. - `request`: absolute deadline from the first byte of a request head to a complete head (HTTP/1.1). Caps slowloris drip regardless of `idle`. - Default 30000. + On a cleartext listener with `http1_1` and `http2`, also the deadline + from the accept to the bytes that choose the protocol. Default 30000. - `body`: per-chunk idle while receiving a request body; re-arms per chunk. Default 60000. - `body_deadline`: absolute deadline from head-complete to a fully diff --git a/src/nhttp_conn.erl b/src/nhttp_conn.erl index 120154d..d2678e8 100644 --- a/src/nhttp_conn.erl +++ b/src/nhttp_conn.erl @@ -39,6 +39,7 @@ activate_n/2, alt_svc_headers/2, cancel_hibernate_timer/1, + classify_preface/1, compress_config/1, deadline_remaining/1, emit_handler_exception/3, @@ -91,6 +92,7 @@ Alt-Svc advertisement of the HTTP/3 endpoint. %% LOCAL MACROS %%%----------------------------------------------------------------------------- -define(ACTIVE_N, 100). +-define(H2_PREFACE_LINE, <<"PRI * HTTP/2.0">>). -define(H2_SEND_QUEUE_BOUNDS, #{max_send_buffer => infinity, max_queued_streams => infinity}). -define(SOCKET_TRANSFER_TIMEOUT, 5000). @@ -108,6 +110,11 @@ drain(Pid) -> Pid ! shutdown, ok. +-doc "The spawn options of the processes that run handler code.". +-spec spawn_opts(nhttp:opts()) -> [nhttp:conn_spawn_opt()]. +spawn_opts(Opts) -> + maps:get(conn_spawn_opts, Opts, ?DEFAULT_CONN_SPAWN_OPTS). + -doc """ Start a connection under `nhttp_conn_sup` without waiting for `init/1`. A failed handler `init/1` ends the process with `{shutdown, _}`. @@ -117,11 +124,6 @@ start_link_supervised(Name, Socket, Opts, Ctx) -> Args = [{Name, Socket, Opts, Ctx, self()}], {ok, proc_lib:spawn_opt(?MODULE, init, Args, [link | spawn_opts(Opts)])}. --doc "The spawn options of the processes that run handler code.". --spec spawn_opts(nhttp:opts()) -> [nhttp:conn_spawn_opt()]. -spawn_opts(Opts) -> - maps:get(conn_spawn_opts, Opts, ?DEFAULT_CONN_SPAWN_OPTS). - %%%----------------------------------------------------------------------------- %% PROC_LIB CALLBACKS %%%----------------------------------------------------------------------------- @@ -176,11 +178,14 @@ init({Name, Socket, Opts, Ctx, Parent}) -> -doc """ `nhttp_protocol` entry point for the pre-classification phase: blocks on the acceptor's socket handoff, classifies the protocol, then tail-calls -the selected protocol module's `loop/3`. +the selected protocol module's `loop/3`. A cleartext listener that serves +both HTTP/1.1 and HTTP/2 first reads until the bytes decide. """. -spec loop(pid(), [sys:debug_option()], #state{}) -> no_return(). -loop(Parent, Debug, #state{family = undefined} = State) -> - wait_for_socket(Parent, Debug, State). +loop(Parent, Debug, #state{family = undefined, protocol_state = undefined} = State) -> + wait_for_socket(Parent, Debug, State); +loop(Parent, Debug, #state{family = undefined, protocol_state = #sniff_state{}} = State) -> + sniff_loop(Parent, Debug, State). -spec system_code_change(#state{}, module(), term(), term()) -> {ok, #state{}}. ?NHTTP_SYSTEM_CODE_CHANGE_NOOP. @@ -311,6 +316,12 @@ enter_protocol_loop(Parent, Debug, #state{family = http1} = State) -> enter_protocol_loop(Parent, Debug, #state{family = http2} = State) -> nhttp_conn_h2:loop(Parent, Debug, State). +-spec enter_protocol_loop(pid(), [sys:debug_option()], #state{}, binary()) -> no_return(). +enter_protocol_loop(Parent, Debug, #state{family = http1} = State, Data) -> + nhttp_conn_h1:loop(Parent, Debug, State, Data); +enter_protocol_loop(Parent, Debug, #state{family = http2} = State, Data) -> + nhttp_conn_h2:loop(Parent, Debug, State, Data). + -spec set_active(nhttp_sock:t(), [gen_tcp:option() | ssl:tls_option()]) -> ok | {stop, term()}. set_active(Socket, Opts) -> case nhttp_sock:setopts(Socket, Opts) of @@ -471,11 +482,29 @@ transport_to_scheme(ssl) -> https. %%%----------------------------------------------------------------------------- %% INTERNAL FUNCTIONS - PROTOCOL DETECTION %%%----------------------------------------------------------------------------- --spec detect_protocol(nhttp_sock:t(), [nhttp:version()]) -> - {ok, http1 | http2} | {error, {unsupported_alpn, binary()}}. -detect_protocol(Socket, Versions) -> +-doc """ +Classify a cleartext connection from its first bytes (RFC 9113 §3.4). +The HTTP/2 preface starts with `PRI * HTTP/2.0`, and no HTTP/1.x request +does, so its 14 bytes decide, or the first byte that differs from them. +`more` means that the bytes are a strict prefix of them. +""". +-spec classify_preface(binary()) -> {ok, http1 | http2} | more. +classify_preface(Bytes) -> + Line = ?H2_PREFACE_LINE, + case binary:longest_common_prefix([Bytes, Line]) of + Common when Common =:= byte_size(Line) -> {ok, http2}; + Common when Common =:= byte_size(Bytes) -> more; + _ -> {ok, http1} + end. + +-spec detect_protocol(#state{}) -> + {ok, http1 | http2} | sniff | {error, {unsupported_alpn, binary()}}. +detect_protocol(#state{socket = Socket, transport = Transport, ctx = #{versions := Versions}}) -> AllowedFamilies = [version_to_family(V) || V <- Versions], - select_family(nhttp_sock:negotiated_protocol(Socket), AllowedFamilies). + case sniffs(Transport, AllowedFamilies) of + true -> sniff; + false -> select_family(nhttp_sock:negotiated_protocol(Socket), AllowedFamilies) + end. -spec family_if_allowed(http1 | http2, binary(), [http1 | http2 | http3]) -> {ok, http1 | http2} | {error, {unsupported_alpn, binary()}}. @@ -490,7 +519,7 @@ h1_state_from_opts(Opts) -> Timeouts = maps:get(timeouts, Opts, #{}), #h1_state{ body_timeout = maps:get(body, Timeouts, ?DEFAULT_BODY_TIMEOUT), - request_timeout = maps:get(request, Timeouts, ?DEFAULT_REQUEST_TIMEOUT), + request_timeout = request_timeout(Opts), body_deadline = maps:get(body_deadline, Timeouts, infinity) }. @@ -526,13 +555,25 @@ h2_stream_credit({delay, Ms}) -> {delay, Ms, queue:new()}; h2_stream_credit(on_response) -> on_response. -spec init_protocol(#state{}) -> {ok, #state{}} | {error, nhttp_sock:socket_error()}. -init_protocol(#state{family = http2, socket = Socket, peer = Peer, opts = Opts} = State) -> +init_protocol(#state{socket = Socket} = State) -> + maybe + {ok, State1} ?= open_protocol(State), + ok ?= nhttp_sock:setopts(Socket, [{active, once}]), + {ok, State1} + end. + +-doc """ +Build the protocol state of the classified family and send what the +server sends first: the SETTINGS frame of the HTTP/2 preface, nothing for +HTTP/1.1. The socket stays passive. +""". +-spec open_protocol(#state{}) -> {ok, #state{}} | {error, nhttp_sock:socket_error()}. +open_protocol(#state{family = http2, socket = Socket, peer = Peer, opts = Opts} = State) -> H2Conn0 = nhttp_h2:new(server, h2_settings_from_opts(Opts)), H2Conn = nhttp_h2:set_peer(H2Conn0, Peer), Preface = nhttp_h2:preface(H2Conn), maybe ok ?= nhttp_sock:send(Socket, Preface), - ok ?= nhttp_sock:setopts(Socket, [{active, once}]), {ok, State#state{ protocol_state = #h2_state{ h2_conn = H2Conn, @@ -546,11 +587,8 @@ init_protocol(#state{family = http2, socket = Socket, peer = Peer, opts = Opts} } }} end; -init_protocol(#state{family = http1, socket = Socket, opts = Opts} = State) -> - case nhttp_sock:setopts(Socket, [{active, once}]) of - ok -> {ok, State#state{protocol_state = h1_state_from_opts(Opts)}}; - {error, _} = Error -> Error - end. +open_protocol(#state{family = http1, opts = Opts} = State) -> + {ok, State#state{protocol_state = h1_state_from_opts(Opts)}}. -spec put_h2_initial_window_size(nhttp:opts(), nhttp_h2:settings()) -> nhttp_h2:settings(). put_h2_initial_window_size(#{h2_initial_window_size := Size}, Settings) -> @@ -564,6 +602,10 @@ put_h2_max_frame_size(#{h2_max_frame_size := Size}, Settings) -> put_h2_max_frame_size(#{}, Settings) -> Settings. +-spec request_timeout(nhttp:opts()) -> timeout(). +request_timeout(Opts) -> + maps:get(request, maps:get(timeouts, Opts, #{}), ?DEFAULT_REQUEST_TIMEOUT). + -spec select_family({ok, binary()} | {error, no_alpn}, [http1 | http2 | http3]) -> {ok, http1 | http2} | {error, {unsupported_alpn, binary()}}. select_family({ok, <<"h2">> = Protocol}, AllowedFamilies) -> @@ -577,6 +619,16 @@ select_family({error, no_alpn}, [http2 | _]) -> select_family({error, no_alpn}, _AllowedFamilies) -> {ok, http1}. +-doc """ +Whether the protocol comes from the first bytes: on a cleartext socket +that serves both HTTP/1.1 and HTTP/2. With TLS, ALPN decides. +""". +-spec sniffs(tcp | ssl, [http1 | http2 | http3]) -> boolean(). +sniffs(tcp, AllowedFamilies) -> + lists:member(http1, AllowedFamilies) andalso lists:member(http2, AllowedFamilies); +sniffs(ssl, _AllowedFamilies) -> + false. + -spec version_to_family(nhttp_lib:version()) -> http1 | http2 | http3. version_to_family(http1_0) -> http1; version_to_family(http1_1) -> http1; @@ -735,8 +787,8 @@ sock_stop_reason(Reason) -> end. -spec start_protocol(pid(), [sys:debug_option()], #state{}) -> no_return(). -start_protocol(Parent, Debug, #state{socket = Socket, ctx = #{versions := Versions}} = State) -> - case detect_protocol(Socket, Versions) of +start_protocol(Parent, Debug, State) -> + case detect_protocol(State) of {ok, Family} -> State1 = put_log_ctx(finalize_peer(State#state{family = Family})), case init_protocol(State1) of @@ -746,6 +798,8 @@ start_protocol(Parent, Debug, #state{socket = Socket, ctx = #{versions := Versio {error, Reason} -> wait_for_socket_terminate(Reason, State1) end; + sniff -> + start_sniff(Parent, Debug, State); {error, Reason} -> wait_for_socket_terminate(Reason, State) end. @@ -783,6 +837,86 @@ wait_for_socket_terminate(Reason, State) -> ok = terminate(Reason, State), exit(init_exit_reason(Reason)). +%%%----------------------------------------------------------------------------- +%% INTERNAL FUNCTIONS - PREFACE SNIFFING (RFC 9113 §3.3) +%%%----------------------------------------------------------------------------- +-spec enter_sniffed(pid(), [sys:debug_option()], #state{}, binary()) -> no_return(). +enter_sniffed(Parent, Debug, State, Bytes) -> + State1 = put_log_ctx(finalize_peer(State)), + case open_protocol(State1) of + {ok, State2} -> + enter_protocol_loop(Parent, Debug, emit_connection_start(State2), Bytes); + {error, Reason} -> + wait_for_socket_terminate(Reason, State1) + end. + +-spec handle_sniff_data(pid(), [sys:debug_option()], #state{}, binary()) -> no_return(). +handle_sniff_data( + Parent, Debug, #state{protocol_state = #sniff_state{buffer = Buffer} = Sniff} = State, Data +) -> + Bytes = sniff_bytes(Buffer, Data), + case classify_preface(Bytes) of + {ok, Family} -> + enter_sniffed(Parent, Debug, State#state{family = Family}, Bytes); + more -> + Sniffing = State#state{protocol_state = Sniff#sniff_state{buffer = Bytes}}, + case activate(Sniffing) of + ok -> sniff_loop(Parent, Debug, Sniffing); + {stop, Reason} -> stop(Reason, Sniffing) + end + end. + +-spec sniff_bytes(binary(), binary()) -> binary(). +sniff_bytes(<<>>, Data) -> + Data; +sniff_bytes(Buffer, Data) -> + <>. + +-doc """ +Wait for the bytes that decide the protocol of a cleartext connection. +The wait ends at the `request` deadline, counted from the socket handoff, +or after the `idle` timeout, whichever comes first. Nothing is sent to a +connection that ends here, because its protocol is not known. +""". +-spec sniff_loop(pid(), [sys:debug_option()], #state{}) -> no_return(). +sniff_loop( + Parent, + Debug, + #state{idle_timeout = IdleTimeout, protocol_state = #sniff_state{request_deadline = Deadline}} = + State +) -> + receive + {tcp, _Socket, Data} -> + handle_sniff_data(Parent, Debug, State, Data); + {tcp_closed, _Socket} -> + stop(normal, State); + {tcp_error, _Socket, Reason} -> + stop({socket_error, Reason}, State); + {system, From, Request} -> + sys:handle_system_msg(Request, From, Parent, ?MODULE, Debug, State); + shutdown -> + stop(normal, State); + {'EXIT', Parent, Reason} -> + stop_parent(Reason, State) + after min(IdleTimeout, deadline_remaining(Deadline)) -> + stop(sniff_timeout_reason(Deadline), State) + end. + +-spec sniff_timeout_reason(deadline()) -> request_timeout | idle_timeout. +sniff_timeout_reason(Deadline) -> + case deadline_remaining(Deadline) of + 0 -> request_timeout; + _ -> idle_timeout + end. + +-spec start_sniff(pid(), [sys:debug_option()], #state{}) -> no_return(). +start_sniff(Parent, Debug, #state{socket = Socket, opts = Opts} = State) -> + Sniff = #sniff_state{request_deadline = idle_deadline(request_timeout(Opts))}, + case nhttp_sock:setopts(Socket, [{active, once}]) of + ok -> sniff_loop(Parent, Debug, State#state{protocol_state = Sniff}); + {error, Reason} -> wait_for_socket_terminate(Reason, State) + end. + %%%----------------------------------------------------------------------------- %% INTERNAL FUNCTIONS - TERMINATION %%%----------------------------------------------------------------------------- diff --git a/src/nhttp_conn.hrl b/src/nhttp_conn.hrl index 37fef86..2e12c48 100644 --- a/src/nhttp_conn.hrl +++ b/src/nhttp_conn.hrl @@ -12,9 +12,10 @@ %%% `#state{}` holds only protocol-agnostic fields. Protocol-specific %%% state lives under `protocol_state`: `#h1_state{}` for `family` %%% `http1` and `websocket` (the H1 upgrade keeps its record, ws fields -%%% included), `#h2_state{}` for `http2`, and `undefined` before -%%% classification in `nhttp_conn`. H3 has its own `#state{}` in -%%% `nhttp_conn_h3.hrl`. +%%% included), `#h2_state{}` for `http2`, `undefined` before the socket +%%% arrives, and `#sniff_state{}` while `nhttp_conn` reads the first bytes +%%% of a cleartext connection that can be either. H3 has its own +%%% `#state{}` in `nhttp_conn_h3.hrl`. %%%----------------------------------------------------------------------------- -ifndef(NHTTP_CONN_HRL). @@ -57,6 +58,11 @@ started = false :: boolean() }). +-record(sniff_state, { + buffer = <<>> :: binary(), + request_deadline :: nhttp_conn:deadline() +}). + -record(h1_state, { buffer = <<>> :: binary(), body_stream :: nhttp_h1:body_stream() | undefined, @@ -109,7 +115,7 @@ handler :: module(), handler_state :: term(), family :: http1 | http2 | websocket | undefined, - protocol_state :: undefined | #h1_state{} | #h2_state{}, + protocol_state :: undefined | #sniff_state{} | #h1_state{} | #h2_state{}, opts :: nhttp:opts(), ctx :: nhttp_conn:ctx(), log_ctx :: nhttp_log:ctx() | undefined, diff --git a/src/nhttp_conn_compress.erl b/src/nhttp_conn_compress.erl index 7e873e6..e4eb2bb 100644 --- a/src/nhttp_conn_compress.erl +++ b/src/nhttp_conn_compress.erl @@ -45,7 +45,6 @@ config(Enabled, Level, Threshold, MimeTypes) -> -doc """ Compress a response body with the coding that the request `Accept-Encoding` selects, and add `vary: accept-encoding` when the selection depends on it. - The response is returned unchanged when compression is disabled, the status has no content or holds a range (1xx, 204, 206, 304), the body is empty, smaller than the threshold or of a type outside the compressible list, the diff --git a/src/nhttp_conn_h1.erl b/src/nhttp_conn_h1.erl index f09e95d..4121eb1 100644 --- a/src/nhttp_conn_h1.erl +++ b/src/nhttp_conn_h1.erl @@ -15,7 +15,8 @@ %% API EXPORTS %%%----------------------------------------------------------------------------- -export([ - loop/3 + loop/3, + loop/4 ]). %%%----------------------------------------------------------------------------- @@ -73,6 +74,14 @@ WebSocket session. loop(Parent, Debug, State) -> h1_loop(Parent, Debug, State). +-doc """ +Entry point with the first bytes of the connection, which `nhttp_conn` +read to classify a cleartext connection. The socket is passive. +""". +-spec loop(pid(), [sys:debug_option()], #state{}, binary()) -> no_return(). +loop(Parent, Debug, State, Data) -> + handle_h1_data(Parent, Debug, State, Data). + %%%----------------------------------------------------------------------------- %% PROC_LIB / SYS CALLBACKS %%%----------------------------------------------------------------------------- diff --git a/src/nhttp_conn_h2.erl b/src/nhttp_conn_h2.erl index 0b9c18a..83ad508 100644 --- a/src/nhttp_conn_h2.erl +++ b/src/nhttp_conn_h2.erl @@ -15,7 +15,8 @@ %% API EXPORTS %%%----------------------------------------------------------------------------- -export([ - loop/3 + loop/3, + loop/4 ]). %%%----------------------------------------------------------------------------- @@ -82,6 +83,14 @@ protocol classification picks `http2`. loop(Parent, Debug, State) -> h2_loop(Parent, Debug, State). +-doc """ +Entry point with the first bytes of the connection, which `nhttp_conn` +read to classify a cleartext connection. The socket is passive. +""". +-spec loop(pid(), [sys:debug_option()], #state{}, binary()) -> no_return(). +loop(Parent, Debug, State, Data) -> + handle_h2_data(Parent, Debug, State, Data). + %%%----------------------------------------------------------------------------- %% PROC_LIB / SYS CALLBACKS %%%----------------------------------------------------------------------------- diff --git a/test/nhttp_compliance.erl b/test/nhttp_compliance.erl index b5f705e..138c098 100644 --- a/test/nhttp_compliance.erl +++ b/test/nhttp_compliance.erl @@ -2,8 +2,9 @@ %%% @doc HTTP/2 RFC 9113 compliance test server. %%% %%% This module provides a minimal HTTP/2 server for running h2spec compliance -%%% tests. It starts an HTTP/2 over TLS server and prints the port in a -%%% parseable format for use by the Makefile. +%%% tests. It starts an HTTP/2 over TLS server, or a cleartext server with +%%% HTTP/1.1 and h2c on one port, and prints the port in a parseable format +%%% for use by the Makefile. %%% %%% Usage from Makefile: %%% erl -noinput -pa ebin -eval 'nhttp_compliance:start().' @@ -16,7 +17,8 @@ -export([ start/0, start/1, - start_h2c/0 + start_h2c/0, + start_mixed/0 ]). -export([ @@ -36,6 +38,15 @@ start() -> start_h2c() -> start(#{tls => false}). +-doc """ +Start the server in cleartext with HTTP/1.1 and h2c on one port. +HTTP/1.x requests go to the `nhttp_h1_compliance` handler, so h2spec and +h1spec run against the same port. +""". +-spec start_mixed() -> no_return(). +start_mixed() -> + start(#{tls => false, versions => [http1_1, http2]}). + -spec start(map()) -> no_return(). start(Opts) -> {ok, _} = application:ensure_all_started(crypto), @@ -46,7 +57,10 @@ start(Opts) -> {ok, _} = application:ensure_all_started(nhttp), {ok, Pid} = nhttp:start_link( - maps:merge(#{port => 0, versions => [http2], handler => ?MODULE}, tls_opts(Opts)) + maps:merge( + #{port => 0, versions => maps:get(versions, Opts, [http2]), handler => ?MODULE}, + tls_opts(Opts) + ) ), {ok, Port} = nhttp:get_port(Pid), @@ -84,13 +98,19 @@ connection reset the stream with NO_ERROR (RFC 9113 Section 8.1), and the codec then discards the frames that h2spec sends to provoke a stream error. """. -spec handle_request(nhttp_lib:request(), #{}) -> nhttp_handler:request_result(#{}). +handle_request(#{version := Version} = Req, State) when + Version =:= http1_0; Version =:= http1_1 +-> + nhttp_h1_compliance:handle_request(Req, State); handle_request(#{body := streaming}, State) -> {accept_body, unread, State}; handle_request(_Req, State) -> {reply, nhttp_resp:ok(<<"hello">>), State}. --spec handle_request_body(nhttp_handler:body_event(), unread, #{}) -> +-spec handle_request_body(nhttp_handler:body_event(), unread | [binary()], #{}) -> nhttp_handler:request_result(#{}). +handle_request_body(Event, Acc, State) when is_list(Acc) -> + nhttp_h1_compliance:handle_request_body(Event, Acc, State); handle_request_body({data, _Chunk}, unread, State) -> {accept_body, unread, State}; handle_request_body({fin, _Trailers}, unread, State) -> diff --git a/test/nhttp_conn_unit_SUITE.erl b/test/nhttp_conn_unit_SUITE.erl index 9f60220..d5d20cb 100644 --- a/test/nhttp_conn_unit_SUITE.erl +++ b/test/nhttp_conn_unit_SUITE.erl @@ -12,6 +12,8 @@ -include("../src/nhttp_conn_ws.hrl"). -include("../src/nhttp_ws_codes.hrl"). +-define(H2_PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). + -compile([export_all, nowarn_export_all]). %%%----------------------------------------------------------------------------- @@ -25,6 +27,8 @@ all() -> sock_stop_reason_test, exit_reason_test, select_family_test, + classify_preface_test, + classify_preface_split_test, version_family_mapping_test, route_silent_test, ws_str_or_atom_test, @@ -117,6 +121,33 @@ select_family_test(_Config) -> ?assertEqual({ok, http1}, nhttp_conn:select_family({error, no_alpn}, [http1, http2])), ?assertEqual({ok, http1}, nhttp_conn:select_family({error, no_alpn}, [http3])). +classify_preface_test(_Config) -> + ?assertEqual(more, nhttp_conn:classify_preface(<<>>)), + ?assertEqual(more, nhttp_conn:classify_preface(<<"PRI * HTTP/2">>)), + ?assertEqual({ok, http2}, nhttp_conn:classify_preface(<<"PRI * HTTP/2.0">>)), + ?assertEqual({ok, http2}, nhttp_conn:classify_preface(?H2_PREFACE)), + ?assertEqual({ok, http1}, nhttp_conn:classify_preface(<<"G">>)), + ?assertEqual({ok, http1}, nhttp_conn:classify_preface(<<"PRI / HTTP/1.1\r\n">>)), + ?assertEqual({ok, http1}, nhttp_conn:classify_preface(<<"PRI * HTTP/1.1\r\n">>)). + +classify_preface_split_test(_Config) -> + lists:foreach( + fun(Input) -> + Whole = nhttp_conn:classify_preface(Input), + lists:foreach( + fun(Chunks) -> ?assertEqual(Whole, feed_classify(Chunks, <<>>)) end, + chunkings(Input) + ) + end, + [ + <<"PRI * HTTP/2.0\r\n">>, + <<"GET / HTTP/1.1\r\n">>, + <<"PRI / HTTP/1.1\r\n">>, + <<"PRI * HTTP/1.1\r\n">>, + <<"PRI * HTTP/2">> + ] + ). + version_family_mapping_test(_Config) -> ?assertEqual(http1, nhttp_conn:version_to_family(http1_0)), ?assertEqual(http1, nhttp_conn:version_to_family(http1_1)), @@ -333,3 +364,21 @@ await_exit(WPid) -> after 1000 -> ct:fail(worker_did_not_exit) end. + +feed_classify([], Acc) -> + nhttp_conn:classify_preface(Acc); +feed_classify([Chunk | Rest], Acc) -> + Bytes = <>, + case nhttp_conn:classify_preface(Bytes) of + more -> feed_classify(Rest, Bytes); + Result -> Result + end. + +chunkings(<<>>) -> + [[]]; +chunkings(Bin) -> + [ + [binary:part(Bin, 0, N) | Rest] + || N <- lists:seq(1, byte_size(Bin)), + Rest <- chunkings(binary:part(Bin, N, byte_size(Bin) - N)) + ]. diff --git a/test/nhttp_h2c_SUITE.erl b/test/nhttp_h2c_SUITE.erl index c4a2d0f..7e4eb69 100644 --- a/test/nhttp_h2c_SUITE.erl +++ b/test/nhttp_h2c_SUITE.erl @@ -2,7 +2,9 @@ -moduledoc """ HTTP/2 over cleartext TCP with prior knowledge (RFC 9113 §3.3): a listener -with `versions => [http2]` and no `tls`. +with `versions => [http2]` and no `tls`, and a listener with +`versions => [http1_1, http2]` and no `tls`, which picks the protocol of +each connection from its first bytes. """. %%%----------------------------------------------------------------------------- @@ -10,6 +12,7 @@ with `versions => [http2]` and no `tls`. %%%----------------------------------------------------------------------------- -include_lib("common_test/include/ct.hrl"). -include_lib("stdlib/include/assert.hrl"). +-include("../src/nhttp_conn.hrl"). %%%----------------------------------------------------------------------------- %%% COMMON TEST CALLBACKS @@ -17,6 +20,9 @@ with `versions => [http2]` and no `tls`. -export([ all/0, end_per_testcase/2, + groups/0, + init_per_group/2, + end_per_group/2, init_per_testcase/2 ]). @@ -28,6 +34,14 @@ with `versions => [http2]` and no `tls`. drain_sends_goaway/1, get_request/1, h1_request_gets_goaway/1, + mixed_drain_closes_unclassified/1, + mixed_h1_keep_alive/1, + mixed_h1_pri_method/1, + mixed_h2c_get/1, + mixed_h2c_preface_byte_per_packet/1, + mixed_partial_preface_closed/1, + mixed_silent_client_closed/1, + mixed_sys_get_state_resumes/1, post_with_body/1, request_scheme_is_http/1 ]). @@ -46,6 +60,11 @@ with `versions => [http2]` and no `tls`. -define(RECV_TIMEOUT, 5000). -define(CONCURRENT_STREAMS, 100). -define(PROTOCOL_ERROR, 1). +-define(SNIFF_REQUEST_TIMEOUT, 200). +-define(SNIFF_CLOSE_WAIT, 2000). +-define(H2_PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). +-define(H2_EMPTY_SETTINGS, <<0, 0, 0, 4, 0, 0, 0, 0, 0>>). +-define(H1_GET, <<"GET /reply HTTP/1.1\r\nHost: localhost\r\n\r\n">>). init(TestPid) -> {ok, #{test => TestPid}}. @@ -67,17 +86,44 @@ handle_request_body({fin, _Trailers}, Acc, State) -> %%% SUITE CALLBACKS %%%----------------------------------------------------------------------------- all() -> + [{group, h2c}, {group, mixed}]. + +groups() -> [ - get_request, - post_with_body, - concurrent_streams, - request_scheme_is_http, - drain_sends_goaway, - h1_request_gets_goaway + {h2c, [], [ + get_request, + post_with_body, + concurrent_streams, + request_scheme_is_http, + drain_sends_goaway, + h1_request_gets_goaway + ]}, + {mixed, [], [ + mixed_h1_keep_alive, + mixed_h1_pri_method, + mixed_h2c_get, + mixed_h2c_preface_byte_per_packet, + mixed_silent_client_closed, + mixed_partial_preface_closed, + mixed_drain_closes_unclassified, + mixed_sys_get_state_resumes + ]} ]. -init_per_testcase(_TestCase, Config) -> - {Pid, Port} = nhttp_test_helpers:h2c_start_server(?MODULE, #{handler_args => self()}), +init_per_group(h2c, Config) -> + [{versions, [http2]} | Config]; +init_per_group(mixed, Config) -> + [{versions, [http1_1, http2]} | Config]. + +end_per_group(_Group, _Config) -> + ok. + +init_per_testcase(TestCase, Config) -> + Extra = maps:merge( + #{handler_args => self(), versions => ?config(versions, Config)}, + testcase_opts(TestCase) + ), + {Pid, Port} = nhttp_test_helpers:h2c_start_server(?MODULE, Extra), [{server, Pid}, {port, Port} | Config]. end_per_testcase(_TestCase, Config) -> @@ -155,9 +201,93 @@ h1_request_gets_goaway(Config) -> ), ok = gen_tcp:close(Sock). +mixed_h1_keep_alive(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + ?assertMatch(<<"HTTP/1.1 200", _/binary>>, h1_get(Sock)), + ?assertMatch(<<"HTTP/1.1 200", _/binary>>, h1_get(Sock)), + ok = gen_tcp:close(Sock). + +mixed_h1_pri_method(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + ok = gen_tcp:send(Sock, <<"PRI /reply HTTP/1.1\r\nHost: localhost\r\n\r\n">>), + ?assertMatch({ok, <<"HTTP/1.1 200", _/binary>>}, gen_tcp:recv(Sock, 0, ?RECV_TIMEOUT)), + ok = gen_tcp:close(Sock). + +mixed_h2c_get(Config) -> + {ok, Sock} = nhttp_test_helpers:h2c_connect(?config(port, Config)), + ok = h2c_get(Sock), + ok = gen_tcp:close(Sock). + +mixed_h2c_preface_byte_per_packet(Config) -> + {ok, Sock} = gen_tcp:connect( + "127.0.0.1", ?config(port, Config), [binary, {active, false}, {nodelay, true}] + ), + lists:foreach(fun(Byte) -> ok = gen_tcp:send(Sock, <>) end, binary_to_list(?H2_PREFACE)), + ok = gen_tcp:send(Sock, ?H2_EMPTY_SETTINGS), + ok = h2c_get(Sock), + ok = gen_tcp:close(Sock). + +mixed_silent_client_closed(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + ?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, ?SNIFF_CLOSE_WAIT)). + +mixed_partial_preface_closed(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + ok = gen_tcp:send(Sock, <<"PRI * HTTP/2">>), + ?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, ?SNIFF_CLOSE_WAIT)). + +mixed_drain_closes_unclassified(Config) -> + Server = ?config(server, Config), + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + [Conn] = nhttp_test_helpers:wait_for_conns(Server, 1, ?RECV_TIMEOUT), + ok = wait_until_sniffing(Conn), + MRef = erlang:monitor(process, Conn), + ok = nhttp:drain(Server, ?SNIFF_CLOSE_WAIT), + receive + {'DOWN', MRef, process, Conn, Reason} -> ?assertEqual(normal, Reason) + after ?SNIFF_CLOSE_WAIT -> ct:fail(conn_alive_after_drain) + end, + ?assertEqual({error, closed}, gen_tcp:recv(Sock, 0, ?SNIFF_CLOSE_WAIT)). + +mixed_sys_get_state_resumes(Config) -> + Sock = nhttp_test_helpers:tcp_connect(?config(port, Config)), + [Conn] = nhttp_test_helpers:wait_for_conns(?config(server, Config), 1, ?RECV_TIMEOUT), + ok = wait_until_sniffing(Conn), + ?assertMatch(<<"HTTP/1.1 200", _/binary>>, h1_get(Sock)), + ok = gen_tcp:close(Sock). + %%%----------------------------------------------------------------------------- %%% HELPERS %%%----------------------------------------------------------------------------- +testcase_opts(TestCase) when + TestCase =:= mixed_silent_client_closed; TestCase =:= mixed_partial_preface_closed +-> + #{timeouts => #{idle => ?RECV_TIMEOUT, request => ?SNIFF_REQUEST_TIMEOUT}}; +testcase_opts(_TestCase) -> + #{}. + +wait_until_sniffing(Conn) -> + nhttp_test_helpers:wait_until( + fun() -> + case sys:get_state(Conn) of + #state{protocol_state = #sniff_state{}} -> true; + #state{} -> false + end + end, + ?RECV_TIMEOUT + ). + +h1_get(Sock) -> + ok = gen_tcp:send(Sock, ?H1_GET), + {ok, Response} = gen_tcp:recv(Sock, 0, ?RECV_TIMEOUT), + Response. + +h2c_get(Sock) -> + 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)). + recv_until_done(Sock, StreamIds, Buf, Acc) -> case lists:all(fun(Id) -> nhttp_test_helpers:h2_stream_done(Acc, Id) end, StreamIds) of true ->