Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ jobs:
- uses: test-summary/action@v2
if: always()
with:
paths: 'compliance-reports/h2spec.xml'
paths: 'compliance-reports/h2spec*.xml'
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."; \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
Expand Down
23 changes: 15 additions & 8 deletions priv/compliance/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@
#
# Runs one compliance suite against a server started for it.
#
# Starts <server-module>:start() in a rebar3 test shell, waits for the
# Starts <entry>() in a rebar3 test shell, waits for the
# PORT:<n> line it prints, runs <command> with PORT in its environment,
# stops the server and its process group, and exits with the status of
# <command>. That status is the receipt: a suite that fails here fails
# the make target and the CI job.
#
# Usage: run.sh <server-module> <command>
# Usage: run.sh <entry> <command>
#
# <entry> is <module>:<function> or a bare <module>, which means
# <module>:start.
#
# <command> is one string, run with `bash -c`, so it reads $PORT at run
# time. Example:
# run.sh nhttp_compliance 'docker run --rm --network=host h2spec -p $PORT'

set -uo pipefail

module=${1:?usage: run.sh <server-module> <command>}
command=${2:?usage: run.sh <server-module> <command>}
entry=${1:?usage: run.sh <entry> <command>}
command=${2:?usage: run.sh <entry> <command>}
case $entry in
*:*) ;;
*) entry=${entry}:start ;;
esac
rebar3=${REBAR3:-rebar3}
start_timeout_ticks=300

Expand All @@ -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=""
Expand All @@ -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"
20 changes: 20 additions & 0 deletions src/nhttp.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions src/nhttp_listener.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
44 changes: 25 additions & 19 deletions test/nhttp_compliance.erl
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

-export([
start/0,
start/1
start/1,
start_h2c/0
]).

-export([
Expand All @@ -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),
Expand All @@ -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),

Expand All @@ -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, #{}}.
Expand Down
28 changes: 15 additions & 13 deletions test/nhttp_conn_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading