Current section

Files

Jump to
quic src quic_interop_server.erl
Raw

src/quic_interop_server.erl

%%% -*- erlang -*-
%%%
%%% QUIC Interop Runner Server
%%% https://github.com/quic-interop/quic-interop-runner
%%%
%%% Copyright (c) 2026 Benoit Chesneau
%%% Apache License 2.0
%%%
%%% @doc Interop runner server for QUIC compliance testing.
%%%
%%% Environment variables:
%%% TESTCASE - Test case name (handshake, transfer, retry, etc.)
%%% SSLKEYLOGFILE - Optional file for TLS key logging
%%%
%%% The server serves files from /www directory and uses certificates
%%% from /certs directory (cert.pem, priv.key).
-module(quic_interop_server).
-export([main/1]).
%% Suppress dialyzer warnings for escript functions that call halt()
-dialyzer({nowarn_function, [main/1, run_server/4]}).
-define(EXIT_SUCCESS, 0).
-define(EXIT_FAILURE, 1).
-define(EXIT_UNSUPPORTED, 127).
%% Supported test cases
-define(SUPPORTED_TESTS, [
"handshake",
"transfer",
"retry",
"keyupdate",
"chacha20",
"multiconnect",
"v2",
"resumption",
"zerortt",
"connectionmigration"
]).
main(_Args) ->
%% Start required applications
application:ensure_all_started(crypto),
application:ensure_all_started(ssl),
%% Get environment variables
TestCase = os:getenv("TESTCASE", "handshake"),
CertsDir = os:getenv("CERTS", "/certs"),
WwwDir = os:getenv("WWW", "/www"),
Port = list_to_integer(os:getenv("PORT", "443")),
io:format("QUIC Interop Server~n"),
io:format(" Test case: ~s~n", [TestCase]),
io:format(" Port: ~p~n", [Port]),
io:format(" Certs: ~s~n", [CertsDir]),
io:format(" WWW: ~s~n", [WwwDir]),
%% Check if test case is supported
case lists:member(TestCase, ?SUPPORTED_TESTS) of
false ->
io:format("Test case ~s not supported~n", [TestCase]),
halt(?EXIT_UNSUPPORTED);
true ->
run_server(TestCase, Port, CertsDir, WwwDir)
end.
run_server(TestCase, Port, CertsDir, WwwDir) ->
%% Load certificates
CertFile = filename:join(CertsDir, "cert.pem"),
KeyFile = filename:join(CertsDir, "priv.key"),
case {file:read_file(CertFile), file:read_file(KeyFile)} of
{{ok, CertPem}, {ok, KeyPem}} ->
%% Decode PEM to DER
[{_, CertDer, _}] = public_key:pem_decode(CertPem),
PrivateKey = decode_private_key(KeyPem),
%% Build server options
Opts = build_server_opts(TestCase, CertDer, PrivateKey, WwwDir),
%% Start listener
case quic_listener:start_link(Port, Opts) of
{ok, Listener} ->
io:format("Server listening on port ~p~n", [Port]),
%% Wait forever (or until killed)
receive
stop ->
quic_listener:stop(Listener),
halt(?EXIT_SUCCESS)
end;
{error, Reason} ->
io:format("Failed to start listener: ~p~n", [Reason]),
halt(?EXIT_FAILURE)
end;
_ ->
io:format("Failed to read certificates~n"),
halt(?EXIT_FAILURE)
end.
build_server_opts(TestCase, Cert, Key, WwwDir) ->
BaseOpts = #{
cert => Cert,
key => Key,
alpn => [<<"hq-interop">>, <<"h3">>],
connection_handler => fun(ConnPid, ConnRef) ->
spawn_handler(ConnPid, ConnRef, WwwDir, TestCase)
end
},
%% Test case specific options
case TestCase of
"retry" ->
BaseOpts#{retry => true};
"chacha20" ->
BaseOpts#{ciphers => [chacha20_poly1305]};
"v2" ->
BaseOpts#{versions => [16#6b3343cf, 16#00000001]};
_ ->
BaseOpts
end.
%% Decode PEM-encoded private key to internal format
decode_private_key(PemData) ->
case public_key:pem_decode(PemData) of
[{Type, Der, not_encrypted}] ->
decode_key_entry(Type, Der);
[{Type, Der, _Cipher}] ->
%% Encrypted key - not supported yet
decode_key_entry(Type, Der);
_ ->
error(invalid_private_key)
end.
decode_key_entry('RSAPrivateKey', Der) ->
public_key:der_decode('RSAPrivateKey', Der);
decode_key_entry('ECPrivateKey', Der) ->
public_key:der_decode('ECPrivateKey', Der);
decode_key_entry('PrivateKeyInfo', Der) ->
%% PKCS#8 format - public_key:der_decode handles extraction automatically
public_key:der_decode('PrivateKeyInfo', Der);
decode_key_entry(Type, _Der) ->
error({unsupported_key_type, Type}).
spawn_handler(ConnPid, ConnRef, WwwDir, TestCase) ->
HandlerPid = spawn(fun() ->
connection_handler(ConnPid, ConnRef, WwwDir, TestCase)
end),
{ok, HandlerPid}.
connection_handler(ConnPid, ConnRef, WwwDir, TestCase) ->
io:format("Handler started, waiting for messages...~n"),
%% Wait for stream data
receive
{quic, ConnRef, {connected, Info}} ->
io:format("Handler got connected: ~p~n", [Info]),
connection_handler(ConnPid, ConnRef, WwwDir, TestCase);
{quic, ConnRef, {stream_opened, StreamId}} ->
io:format("Handler got stream_opened: ~p~n", [StreamId]),
handle_stream(ConnPid, ConnRef, StreamId, WwwDir, TestCase);
{quic, ConnRef, {stream_data, StreamId, Data, Fin}} ->
io:format("Handler got stream_data: stream=~p size=~p fin=~p~n",
[StreamId, byte_size(Data), Fin]),
%% Handle request
handle_request(ConnPid, ConnRef, StreamId, Data, WwwDir, TestCase);
{quic, ConnRef, {closed, Reason}} ->
io:format("Handler got closed: ~p~n", [Reason]),
ok;
Other ->
io:format("Handler got unexpected: ~p~n", [Other]),
connection_handler(ConnPid, ConnRef, WwwDir, TestCase)
after 60000 ->
io:format("Handler timeout~n"),
ok
end.
handle_stream(_ConnPid, ConnRef, StreamId, WwwDir, TestCase) ->
%% Wait for request on this stream
receive
{quic, ConnRef, {stream_data, StreamId, Data, _Fin}} ->
handle_request(undefined, ConnRef, StreamId, Data, WwwDir, TestCase)
after 30000 ->
ok
end.
handle_request(_ConnPid, ConnRef, StreamId, Data, WwwDir, TestCase) ->
io:format("handle_request: stream=~p data=~p~n", [StreamId, Data]),
%% Parse simple HTTP/0.9 request: "GET /path\r\n"
case parse_request(Data) of
{ok, Path} ->
io:format("Parsed request path: ~p~n", [Path]),
%% Handle key update test
case TestCase of
"keyupdate" ->
case quic_connection:lookup(ConnRef) of
{ok, Pid} -> quic_connection:key_update(Pid);
_ -> ok
end;
_ ->
ok
end,
%% Serve file
FilePath = filename:join(WwwDir, Path),
io:format("Reading file: ~p~n", [FilePath]),
case file:read_file(FilePath) of
{ok, Content} ->
io:format("Sending ~p bytes on stream ~p~n", [byte_size(Content), StreamId]),
Result = quic:send_data(ConnRef, StreamId, Content, true),
io:format("send_data result: ~p~n", [Result]),
Result;
{error, ReadErr} ->
io:format("File read error: ~p, sending 404~n", [ReadErr]),
quic:send_data(ConnRef, StreamId, <<"404 Not Found">>, true)
end;
error ->
io:format("Parse error, sending 400~n"),
quic:send_data(ConnRef, StreamId, <<"400 Bad Request">>, true)
end.
parse_request(Data) ->
case binary:split(Data, <<"\r\n">>) of
[RequestLine | _] ->
case binary:split(RequestLine, <<" ">>, [global]) of
[<<"GET">>, Path | _] ->
%% Remove leading slash for file path
CleanPath = case Path of
<<"/">> -> <<"index.html">>;
<<"/", Rest/binary>> -> Rest;
_ -> Path
end,
{ok, binary_to_list(CleanPath)};
_ ->
error
end;
_ ->
error
end.